Provisioning: Add README.md support for Git Sync folders

This change enables fetching and rendering README.md files from Git Sync
provisioned folders in the UI. When a user opens a provisioned folder,
the frontend now fetches the README.md from the repository and renders
it as markdown.

Backend changes:
- Add IsReadablePath() to allow .md files for read-only operations
- Add IsRawFile() to detect raw content files (not k8s resources)
- Add handleGetRawFile() to return raw file content without parsing
- Return raw file content in resource.file.content field

Frontend changes:
- Add FolderReadme component to fetch and render README.md
- Integrate component into BrowseDashboardsPage

Testing:
- Unit tests for path validation and raw file reading
- Integration tests for README.md API operations
This commit is contained in:
Roberto Jimenez Sanchez
2026-01-13 12:52:06 +01:00
parent 91ab753368
commit dca2fd6b8d
9 changed files with 1096 additions and 15 deletions
+150
View File
@@ -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)
@@ -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()