diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index a418860caac..f63fc4f7a59 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) @@ -121,7 +122,7 @@ func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http return } - obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter) + obj, err := c.handleMethodRequest(ctx, r, name, opts, isDir, dualReadWriter, readWriter) if err != nil { logger.Debug("got an error after processing request", "error", err) respondWithError(responder, err) @@ -175,8 +176,16 @@ func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo } opts.Path = path - if err := resources.IsPathSupported(opts.Path); err != nil { - return opts, err + // For GET requests, allow read-only files like .md in addition to resource files + // For write operations, only allow resource files (yml, yaml, json) + if r.Method == http.MethodGet { + if err := resources.IsReadablePath(opts.Path); err != nil { + return opts, err + } + } else { + if err := resources.IsPathSupported(opts.Path); err != nil { + return opts, err + } } return opts, nil @@ -199,10 +208,10 @@ func (c *filesConnector) handleDirectoryListing(ctx context.Context, name string } // handleMethodRequest routes the request to the appropriate handler based on HTTP method. -func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { +func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, repoName string, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) { switch r.Method { case http.MethodGet: - return c.handleGet(ctx, opts, dualReadWriter) + return c.handleGet(ctx, repoName, opts, dualReadWriter, readWriter) case http.MethodPost: return c.handlePost(ctx, r, opts, isDir, dualReadWriter) case http.MethodPut: @@ -214,7 +223,12 @@ func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Reques } } -func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { +func (c *filesConnector) handleGet(ctx context.Context, repoName string, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) { + // For raw files (like .md), read directly without parsing as k8s resource + if resources.IsRawFile(opts.Path) { + return c.handleGetRawFile(ctx, repoName, opts, readWriter) + } + resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref) if err != nil { return nil, err @@ -222,6 +236,36 @@ func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWrite return resource.AsResourceWrapper(), nil } +// handleGetRawFile reads a raw file (like README.md) and returns it without parsing. +func (c *filesConnector) handleGetRawFile(ctx context.Context, repoName string, opts resources.DualWriteOptions, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) { + // Authorize the read operation + if err := c.authorizeListFiles(ctx, repoName); err != nil { + return nil, err + } + + info, err := readWriter.Read(ctx, opts.Path, opts.Ref) + if err != nil { + if err == repository.ErrFileNotFound { + return nil, apierrors.NewNotFound(provisioning.RepositoryResourceInfo.GroupResource(), opts.Path) + } + return nil, fmt.Errorf("read raw file: %w", err) + } + + // Return the raw content in the ResourceWrapper + return &provisioning.ResourceWrapper{ + Path: info.Path, + Ref: info.Ref, + Hash: info.Hash, + Resource: provisioning.ResourceObjects{ + File: v0alpha1.Unstructured{ + Object: map[string]any{ + "content": string(info.Data), + }, + }, + }, + }, nil +} + func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) { // Check if this is a move operation (originalPath query parameter is present) if opts.OriginalPath != "" { diff --git a/pkg/registry/apis/provisioning/files_test.go b/pkg/registry/apis/provisioning/files_test.go new file mode 100644 index 00000000000..245043c19fd --- /dev/null +++ b/pkg/registry/apis/provisioning/files_test.go @@ -0,0 +1,234 @@ +package provisioning + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/auth" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +func TestParseRequestOptionsPathValidation(t *testing.T) { + tests := []struct { + name string + method string + path string + wantErr bool + errContains string + }{ + { + name: "GET json file allowed", + method: http.MethodGet, + path: "dashboard.json", + }, + { + name: "GET yaml file allowed", + method: http.MethodGet, + path: "dashboard.yaml", + }, + { + name: "GET yml file allowed", + method: http.MethodGet, + path: "dashboard.yml", + }, + { + name: "GET markdown file allowed", + method: http.MethodGet, + path: "README.md", + }, + { + name: "GET nested markdown file allowed", + method: http.MethodGet, + path: "folder/subfolder/README.md", + }, + { + name: "GET txt file not allowed", + method: http.MethodGet, + path: "file.txt", + wantErr: true, + errContains: "unsupported file extension", + }, + { + name: "POST json file allowed", + method: http.MethodPost, + path: "dashboard.json", + }, + { + name: "POST markdown file not allowed", + method: http.MethodPost, + path: "README.md", + wantErr: true, + errContains: "unsupported file extension", + }, + { + name: "PUT markdown file not allowed", + method: http.MethodPut, + path: "README.md", + wantErr: true, + errContains: "unsupported file extension", + }, + { + name: "DELETE markdown file not allowed", + method: http.MethodDelete, + path: "README.md", + wantErr: true, + errContains: "unsupported file extension", + }, + { + name: "GET directory allowed", + method: http.MethodGet, + path: "dashboards/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRepo := repository.NewMockRepository(t) + mockRepo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + }, + }).Maybe() + + connector := &filesConnector{} + r := httptest.NewRequest(tt.method, "/test-repo/files/"+tt.path, nil) + + opts, err := connector.parseRequestOptions(r, "test-repo", mockRepo) + + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + require.Equal(t, tt.path, opts.Path) + } + }) + } +} + +func TestHandleGetRawFile(t *testing.T) { + tests := []struct { + name string + path string + fileContent string + readError error + wantErr bool + errContains string + expectedResult string + }{ + { + name: "successful readme read", + path: "README.md", + fileContent: "# Hello World\n\nThis is a test.", + expectedResult: "# Hello World\n\nThis is a test.", + }, + { + name: "nested readme read", + path: "folder/README.md", + fileContent: "# Folder Readme", + expectedResult: "# Folder Readme", + }, + { + name: "file not found", + path: "README.md", + readError: repository.ErrFileNotFound, + wantErr: true, + errContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockReadWriter := repository.NewMockReaderWriter(t) + mockAccess := auth.NewMockAccessChecker(t) + + // Setup auth mock - always allow + mockAccess.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() + + // Setup Read mock + if tt.readError != nil { + mockReadWriter.EXPECT().Read(mock.Anything, tt.path, "").Return(nil, tt.readError) + } else { + mockReadWriter.EXPECT().Read(mock.Anything, tt.path, "").Return(&repository.FileInfo{ + Path: tt.path, + Data: []byte(tt.fileContent), + Ref: "main", + Hash: "abc123", + }, nil) + } + + connector := &filesConnector{ + access: mockAccess, + } + + opts := resources.DualWriteOptions{ + Path: tt.path, + Ref: "", + } + + result, err := connector.handleGetRawFile(context.Background(), "test-repo", opts, mockReadWriter) + + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.path, result.Path) + + // Check that content is in the response + content, ok := result.Resource.File.Object["content"] + require.True(t, ok, "content field should exist") + require.Equal(t, tt.expectedResult, content) + } + }) + } +} + +func TestIsRawFileIntegration(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + { + name: "README.md is raw", + path: "README.md", + expected: true, + }, + { + name: "nested README.md is raw", + path: "folder/subfolder/README.md", + expected: true, + }, + { + name: "dashboard.json is not raw", + path: "dashboard.json", + expected: false, + }, + { + name: "dashboard.yaml is not raw", + path: "dashboard.yaml", + expected: false, + }, + { + name: "directory is not raw", + path: "folder/", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resources.IsRawFile(tt.path) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/registry/apis/provisioning/resources/filepath.go b/pkg/registry/apis/provisioning/resources/filepath.go index f8dbb8caebc..a4817748c57 100644 --- a/pkg/registry/apis/provisioning/resources/filepath.go +++ b/pkg/registry/apis/provisioning/resources/filepath.go @@ -3,6 +3,7 @@ package resources import ( "errors" "path" + "strings" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" ) @@ -15,9 +16,65 @@ var ( const maxPathDepth = 8 -// IsPathSupported checks if the file path is supported by the provisioning API. -// it also validates if the path is safe and if the file extension is supported. +// resourceExtensions are file extensions that contain k8s resources and can be parsed +var resourceExtensions = map[string]bool{ + ".yml": true, + ".yaml": true, + ".json": true, +} + +// readOnlyExtensions are file extensions that can be read as raw content (read-only) +var readOnlyExtensions = map[string]bool{ + ".md": true, +} + +// IsPathSupported checks if the file path is supported by the provisioning API for write operations. +// It validates if the path is safe and if the file extension is a resource type (yml, yaml, json). func IsPathSupported(filePath string) error { + if err := validatePathBasics(filePath); err != nil { + return err + } + + // Only check file extension if it's not a folder path + if !safepath.IsDir(filePath) { + ext := path.Ext(filePath) + if !resourceExtensions[ext] { + return ErrUnsupportedFileExtension + } + } + + return nil +} + +// IsReadablePath checks if the file path is supported for read operations. +// This includes resource files (yml, yaml, json) and read-only files (md). +func IsReadablePath(filePath string) error { + if err := validatePathBasics(filePath); err != nil { + return err + } + + // Only check file extension if it's not a folder path + if !safepath.IsDir(filePath) { + ext := path.Ext(filePath) + if !resourceExtensions[ext] && !readOnlyExtensions[ext] { + return ErrUnsupportedFileExtension + } + } + + return nil +} + +// IsRawFile checks if the file path is a read-only raw file (not a k8s resource). +func IsRawFile(filePath string) bool { + if safepath.IsDir(filePath) { + return false + } + ext := strings.ToLower(path.Ext(filePath)) + return readOnlyExtensions[ext] +} + +// validatePathBasics performs common path validation checks. +func validatePathBasics(filePath string) error { // Validate the path for any traversal attempts first if err := safepath.IsSafe(filePath); err != nil { return err @@ -31,12 +88,5 @@ func IsPathSupported(filePath string) error { return ErrNotRelative } - // Only check file extension if it's not a folder path - if !safepath.IsDir(filePath) { - if ext := path.Ext(filePath); ext != ".yml" && ext != ".yaml" && ext != ".json" { - return ErrUnsupportedFileExtension - } - } - return nil } diff --git a/pkg/registry/apis/provisioning/resources/filepath_test.go b/pkg/registry/apis/provisioning/resources/filepath_test.go index 2ca5ee5da09..dda02500033 100644 --- a/pkg/registry/apis/provisioning/resources/filepath_test.go +++ b/pkg/registry/apis/provisioning/resources/filepath_test.go @@ -38,6 +38,11 @@ func TestIsPathSupported(t *testing.T) { path: "dashboards/my-dashboard.txt", expectedErr: ErrUnsupportedFileExtension, }, + { + name: "markdown file not supported for write", + path: "dashboards/README.md", + expectedErr: ErrUnsupportedFileExtension, + }, { name: "path traversal attempt", path: "../dashboards/my-dashboard.yaml", @@ -70,3 +75,127 @@ func TestIsPathSupported(t *testing.T) { }) } } + +func TestIsReadablePath(t *testing.T) { + tests := []struct { + name string + path string + expectedErr error + }{ + { + name: "valid yaml file", + path: "dashboards/my-dashboard.yaml", + }, + { + name: "valid yml file", + path: "dashboards/my-dashboard.yml", + }, + { + name: "valid json file", + path: "dashboards/my-dashboard.json", + }, + { + name: "valid markdown file", + path: "dashboards/README.md", + }, + { + name: "markdown file in nested path", + path: "dashboards/folder1/folder2/README.md", + }, + { + name: "valid directory path", + path: "dashboards/folder1/", + }, + { + name: "unsupported file extension", + path: "dashboards/my-dashboard.txt", + expectedErr: ErrUnsupportedFileExtension, + }, + { + name: "path traversal attempt", + path: "../dashboards/README.md", + expectedErr: safepath.ErrPathTraversalAttempt, + }, + { + name: "path too deep", + path: "level1/level2/level3/level4/level5/level6/level7/level8/level9/README.md", + expectedErr: ErrPathTooDeep, + }, + { + name: "absolute path", + path: "/etc/dashboards/README.md", + expectedErr: ErrNotRelative, + }, + { + name: "empty directory path", + path: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := IsReadablePath(tt.path) + if tt.expectedErr != nil { + require.ErrorIs(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestIsRawFile(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + { + name: "markdown file is raw", + path: "README.md", + expected: true, + }, + { + name: "nested markdown file is raw", + path: "dashboards/folder/README.md", + expected: true, + }, + { + name: "uppercase MD extension", + path: "README.MD", + expected: true, + }, + { + name: "yaml file is not raw", + path: "dashboard.yaml", + expected: false, + }, + { + name: "json file is not raw", + path: "dashboard.json", + expected: false, + }, + { + name: "yml file is not raw", + path: "dashboard.yml", + expected: false, + }, + { + name: "directory is not raw", + path: "dashboards/", + expected: false, + }, + { + name: "empty path is not raw", + path: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsRawFile(tt.path) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/tests/apis/provisioning/files_test.go b/pkg/tests/apis/provisioning/files_test.go index e31674be505..35638b198b9 100644 --- a/pkg/tests/apis/provisioning/files_test.go +++ b/pkg/tests/apis/provisioning/files_test.go @@ -594,6 +594,156 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) { }) } +func TestIntegrationProvisioning_ReadmeFiles(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "readme-test-repo" + const readmeContent = "# Test Repository\n\nThis is a test README for the provisioning API." + + // Create a repo with a README.md file + helper.CreateRepo(t, TestRepo{ + Name: repo, + Path: helper.ProvisioningPath, + Target: "instance", + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + }, + ExpectedDashboards: 1, + ExpectedFolders: 0, + }) + + // Write README.md directly to the provisioning path + helper.WriteToProvisioningPath(t, "README.md", []byte(readmeContent)) + + t.Run("GET README.md file should succeed", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "should return 200 OK for README.md") + + // Read response body + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Parse the response + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + // Check that the response contains the file content + resource, ok := result["resource"].(map[string]interface{}) + require.True(t, ok, "response should have resource field") + + file, ok := resource["file"].(map[string]interface{}) + require.True(t, ok, "resource should have file field") + + content, ok := file["content"].(string) + require.True(t, ok, "file should have content field") + require.Equal(t, readmeContent, content, "content should match the README") + }) + + t.Run("GET nested README.md should succeed", func(t *testing.T) { + // Write a nested README.md + nestedReadmeContent := "# Nested Folder README\n\nThis is inside a folder." + helper.WriteToProvisioningPath(t, "folder/README.md", []byte(nestedReadmeContent)) + + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/folder/README.md", addr, repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "should return 200 OK for nested README.md") + + // Read response body + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Parse the response + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + // Check the content + resource := result["resource"].(map[string]interface{}) + file := resource["file"].(map[string]interface{}) + content := file["content"].(string) + require.Equal(t, nestedReadmeContent, content, "nested README content should match") + }) + + t.Run("GET non-existent README.md should return 404", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/nonexistent/README.md", addr, repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode, "should return 404 for non-existent README.md") + }) + + t.Run("POST README.md should be rejected", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/new-readme.md", addr, repo) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("# New README")) + require.NoError(t, err) + req.Header.Set("Content-Type", "text/plain") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for POST .md files") + }) + + t.Run("PUT README.md should be rejected", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo) + req, err := http.NewRequest(http.MethodPut, url, bytes.NewBufferString("# Updated README")) + require.NoError(t, err) + req.Header.Set("Content-Type", "text/plain") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for PUT .md files") + }) + + t.Run("DELETE README.md should be rejected", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo) + req, err := http.NewRequest(http.MethodDelete, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for DELETE .md files") + }) + + t.Run("viewer can GET README.md", func(t *testing.T) { + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://viewer:viewer@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "viewer should be able to GET README.md") + }) +} + func TestIntegrationProvisioning_FilesAuthorization(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 34b57afe3c6..5c2cccd1905 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -389,6 +389,17 @@ func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to s require.NoError(t, err, "failed to write file to provisioning path") } +// WriteToProvisioningPath writes content directly to a file in the provisioning path. +func (h *provisioningTestHelper) WriteToProvisioningPath(t *testing.T, to string, content []byte) { + fullPath := path.Join(h.ProvisioningPath, to) + t.Logf("Writing to provisioning path '%s'", fullPath) + err := os.MkdirAll(path.Dir(fullPath), 0o750) + require.NoError(t, err, "failed to create directories for provisioning path") + + err = os.WriteFile(fullPath, content, 0o600) + require.NoError(t, err, "failed to write file to provisioning path") +} + // DebugState logs the current state of filesystem, repository, and Grafana resources for debugging func (h *provisioningTestHelper) DebugState(t *testing.T, repo string, label string) { t.Helper() diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index e177fcf9fa2..8b2b3333e25 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -17,6 +17,7 @@ import { contextSrv } from '../../core/services/context_srv'; import { ManagerKind } from '../apiserver/types'; import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; +import { FolderReadme } from '../provisioning/components/Folders/FolderReadme'; import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner'; import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; import { useSearchStateManager } from '../search/state/SearchStateManager'; @@ -179,6 +180,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record + {folderUID && } {/* only show recently viewed dashboards when in root */} {!folderUID && }
diff --git a/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx b/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx new file mode 100644 index 00000000000..7847aaf06a4 --- /dev/null +++ b/public/app/features/provisioning/components/Folders/FolderReadme.test.tsx @@ -0,0 +1,330 @@ +import { render, screen } from '@testing-library/react'; + +import { config } from '@grafana/runtime'; +import { useGetRepositoryFilesWithPathQuery, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; + +import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView'; + +import { FolderReadme } from './FolderReadme'; + +// Mock dependencies +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: { + provisioning: true, + }, + }, +})); + +jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ + useGetRepositoryFilesWithPathQuery: jest.fn(), +})); + +jest.mock('../../hooks/useGetResourceRepositoryView'); + +const mockUseGetRepositoryFilesWithPathQuery = useGetRepositoryFilesWithPathQuery as jest.MockedFunction< + typeof useGetRepositoryFilesWithPathQuery +>; + +const mockUseGetResourceRepositoryView = useGetResourceRepositoryView as jest.MockedFunction< + typeof useGetResourceRepositoryView +>; + +const mockRepository: RepositoryView = { + name: 'test-repo', + target: 'folder' as const, + title: 'Test Repository', + type: 'git' as const, + workflows: [], +}; + +const mockFolder = { + metadata: { + name: 'test-folder', + annotations: { + 'grafana.app/sourcePath': 'dashboards/team-a', + }, + }, + spec: { + title: 'Test Folder', + }, + status: {}, +}; + +describe('FolderReadme', () => { + beforeEach(() => { + jest.clearAllMocks(); + (config.featureToggles as Record).provisioning = true; + }); + + describe('when provisioning is disabled', () => { + it('should not render anything', () => { + (config.featureToggles as Record).provisioning = false; + + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('when folder is not provisioned', () => { + it('should not render anything when repository is undefined', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: undefined, + folder: undefined, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything when folderUID is undefined', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('when loading', () => { + it('should not render anything while loading repository info', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: true, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should show loading spinner while fetching README', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + render(); + // Check for spinner by looking for the role or a common class + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); + }); + }); + + describe('when README fetch fails', () => { + it('should not render anything on error', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: { status: 404, data: 'Not found' }, + refetch: jest.fn(), + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything when file data is empty', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('when README is successfully fetched', () => { + it('should render markdown content', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: { + resource: { + file: { + content: '# Hello World\n\nThis is a test README.', + }, + }, + }, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + render(); + + // The markdown should be rendered as HTML + expect(screen.getByText('Hello World')).toBeInTheDocument(); + expect(screen.getByText('This is a test README.')).toBeInTheDocument(); + }); + + it('should handle string content directly', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: { + resource: { + file: '# Direct String Content', + }, + }, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + render(); + expect(screen.getByText('Direct String Content')).toBeInTheDocument(); + }); + + it('should not render when file content cannot be extracted', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: { + resource: { + file: { someUnexpectedFormat: true }, + }, + }, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('README path construction', () => { + it('should use source path from folder annotations', () => { + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: mockFolder, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + render(); + + // Verify the query was called with the correct path + expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({ + name: 'test-repo', + path: 'dashboards/team-a/README.md', + }); + }); + + it('should use root README.md when no source path is set', () => { + const folderWithoutPath = { + metadata: { + name: 'test-folder', + annotations: {}, + }, + spec: { title: 'Test Folder' }, + status: {}, + }; + + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: mockRepository, + folder: folderWithoutPath, + isLoading: false, + isInstanceManaged: false, + isReadOnlyRepo: false, + }); + + mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: undefined, + refetch: jest.fn(), + }); + + render(); + + expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({ + name: 'test-repo', + path: 'README.md', + }); + }); + }); +}); diff --git a/public/app/features/provisioning/components/Folders/FolderReadme.tsx b/public/app/features/provisioning/components/Folders/FolderReadme.tsx new file mode 100644 index 00000000000..e4d3ebe3e29 --- /dev/null +++ b/public/app/features/provisioning/components/Folders/FolderReadme.tsx @@ -0,0 +1,131 @@ +import { css } from '@emotion/css'; +import { skipToken } from '@reduxjs/toolkit/query/react'; + +import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Card, Spinner, useStyles2 } from '@grafana/ui'; +import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { AnnoKeySourcePath } from 'app/features/apiserver/types'; + +import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView'; + +interface FolderReadmeProps { + folderUID: string | undefined; +} + +/** + * FolderReadme fetches and renders a README.md file from a Git Sync provisioned folder. + * It only renders when: + * - The provisioning feature is enabled + * - The folder is managed by a repository + * - The README.md file exists and can be fetched + */ +export function FolderReadme({ folderUID }: FolderReadmeProps) { + const styles = useStyles2(getStyles); + const provisioningEnabled = config.featureToggles.provisioning; + + // Get repository info for the folder + const { repository, folder, isLoading: isRepoLoading } = useGetResourceRepositoryView({ + folderName: folderUID, + }); + + // Construct the README path based on the folder's source path annotation + const sourcePath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; + const readmePath = sourcePath ? `${sourcePath}/README.md` : 'README.md'; + + // Determine if we should fetch the README + const shouldFetch = provisioningEnabled && !!repository && !!folderUID && !isRepoLoading; + + // Fetch the README.md file from the repository + const { + data: fileData, + isLoading: isFileLoading, + isError, + } = useGetRepositoryFilesWithPathQuery( + shouldFetch + ? { + name: repository.name, + path: readmePath, + } + : skipToken + ); + + // Don't render if provisioning is disabled or folder is not managed + if (!provisioningEnabled || !folderUID || !repository) { + return null; + } + + // Show loading spinner while fetching repository info + if (isRepoLoading) { + return null; + } + + // Show loading spinner while fetching README + if (isFileLoading) { + return ( + +
+ +
+
+ ); + } + + // Don't render if there was an error (README doesn't exist or unsupported) + if (isError || !fileData) { + return null; + } + + // Extract the raw content from the file data + // The API returns a ResourceWrapper with resource.file containing the file data + const fileContent = fileData.resource?.file; + if (!fileContent) { + return null; + } + + // For markdown files, the content might be in different formats depending on how the API returns it + // Try to get the content as a string + let markdownContent: string | undefined; + + if (typeof fileContent === 'string') { + markdownContent = fileContent; + } else if (typeof fileContent === 'object') { + // If it's an object, try common property names + markdownContent = + (fileContent as Record).content as string | undefined || + (fileContent as Record).data as string | undefined || + (fileContent as Record).spec as string | undefined; + + // If still not found, try to get raw text if available + if (!markdownContent && (fileContent as Record).raw) { + markdownContent = (fileContent as Record).raw as string; + } + } + + // Don't render if we couldn't extract the content + if (!markdownContent || typeof markdownContent !== 'string') { + return null; + } + + // Render the markdown content + const renderedHtml = renderMarkdown(markdownContent); + + return ( + +
+ + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + card: css({ + marginBottom: theme.spacing(2), + padding: theme.spacing(2), + }), + loadingContainer: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing(2), + }), +});