mirror of
https://github.com/grafana/grafana.git
synced 2026-08-19 01:34:54 -05:00
Provisioning: Make files API max file size configurable (#123793)
* Provisioning: Make files API max file size configurable Replace the hard-coded 5 MB cap on writes with a new [provisioning] max_file_size INI key, and apply the same cap to reads (raw files and parsed resources) so large dashboards can round-trip in both directions. 0 disables the limit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Add integration tests for files API max_file_size Wire ProvisioningMaxFileSize through testinfra.GrafanaOpts and add a WithProvisioningMaxFileSize helper in the provisioning common test package. Add a new pkg/tests/apis/provisioning/maxfilesize package with integration tests covering: read of an under-cap raw file, read of an over-cap raw file (HTTP 413), write of an over-cap resource, and the 0 = unlimited semantics on a separate server. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Keep NewAPIBuilder signature stable for enterprise Initialise APIBuilder.maxFileSize to the default 5 MB inside NewAPIBuilder and let RegisterAPIService overwrite it from cfg.ProvisioningMaxFileSize after construction, mirroring the existing pattern used for webhookSecretRotationInterval. This keeps grafana-enterprise's NewAPIBuilder call site unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Drop standalone-server max_file_size integration test The _Disabled variant spun up a second Grafana via RunGrafana, but inside a package whose shared env already owns a Grafana, the two instances share the package-wide test database. In Postgres-backed CI the shared env's controller picks up the second server's sync jobs and rejects them against its own permitted_provisioning_paths. "0 = unlimited" is already covered by TestHandleGetRawFile_MaxFileSize/zero_disables_limit, so drop the flaky integration variant and document why in a comment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Address Copilot review on max_file_size - readBody now returns apierrors.NewRequestEntityTooLargeError on overflow so writes surface HTTP 413, matching read-side behavior. - Document the <=0 = unlimited contract consistently across the INI comment, Cfg field, APIBuilder default and the test option helper. - request_test.go: add zero / negative maxSize cases for readBody. - maxfilesize integration tests: assert HTTP 413 for the oversized POST and verify the under-limit raw GET returns the file verbatim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Address Copilot follow-up on max_file_size - Reject oversized files at the repository boundary instead of after parsing: handleRequest now wraps the ReaderWriter with a sizeLimitedReaderWriter whose Read returns 413 immediately, so DualReadWriter.Read no longer parses or DryRuns oversized payloads. - Centralize the 5 MiB default in setting.ProvisioningMaxFileSizeDefault and reference it from both the config parser and the APIBuilder default initialiser. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Provisioning: Clarify max_file_size unit as MiB in docs Copilot flagged that "5 MB" in the field comment and INI doc string was inconsistent with the bytes value (5242880 = 5 MiB). Reword both to spell out "5 MiB" so operators see the same units everywhere. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Alejandro Malavet <alejandro.malavet@grafana.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Alejandro Malavet
parent
3ffcf379ca
commit
7c62aaa7ee
@@ -2433,6 +2433,12 @@ folders_api_version = v1
|
||||
# Default is 100. If set to 0, the size check is disabled.
|
||||
max_incremental_changes = 100
|
||||
|
||||
# Maximum file size in bytes for files read from or written to a provisioning repository
|
||||
# through the files API. Applies symmetrically to GET (reads) and POST/PUT writes.
|
||||
# Default is 5242880 bytes (5 MiB, i.e. 5 * 1024 * 1024). Set to 0 (or any
|
||||
# non-positive value) for unlimited.
|
||||
max_file_size = 5242880
|
||||
|
||||
# Public-facing root URL of this Grafana instance, used by provisioning to construct URLs
|
||||
# that must be reachable from external systems (e.g. GitHub PR-comment image fetchers and
|
||||
# GitHub webhook deliveries). When empty, falls back to [server] root_url. Set this when
|
||||
|
||||
@@ -23,11 +23,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
const (
|
||||
// Files endpoint max size for dashboards etc (5MB)
|
||||
filesMaxBodySize = 5 * 1024 * 1024
|
||||
)
|
||||
|
||||
type filesConnector struct {
|
||||
getter RepoGetter
|
||||
access auth.AccessChecker
|
||||
@@ -35,9 +30,12 @@ type filesConnector struct {
|
||||
clients resources.ClientFactory
|
||||
folderMetadataEnabled bool
|
||||
folderAPIVersion string
|
||||
// maxFileSize caps the size in bytes of files read from or written to the
|
||||
// repository through this connector. <=0 disables the check.
|
||||
maxFileSize int64
|
||||
}
|
||||
|
||||
func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access auth.AccessChecker, folderMetadataEnabled bool, folderAPIVersion string) *filesConnector {
|
||||
func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access auth.AccessChecker, folderMetadataEnabled bool, folderAPIVersion string, maxFileSize int64) *filesConnector {
|
||||
return &filesConnector{
|
||||
getter: getter,
|
||||
parsers: parsers,
|
||||
@@ -45,9 +43,41 @@ func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clien
|
||||
access: access,
|
||||
folderMetadataEnabled: folderMetadataEnabled,
|
||||
folderAPIVersion: folderAPIVersion,
|
||||
maxFileSize: maxFileSize,
|
||||
}
|
||||
}
|
||||
|
||||
// sizeLimitedReaderWriter wraps a repository.ReaderWriter and rejects reads
|
||||
// that exceed maxBytes. The check fires immediately after the underlying
|
||||
// repository returns the file bytes, so callers (including DualReadWriter
|
||||
// which parses the result) never see oversized payloads.
|
||||
type sizeLimitedReaderWriter struct {
|
||||
repository.ReaderWriter
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
func (s *sizeLimitedReaderWriter) Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) {
|
||||
info, err := s.ReaderWriter.Read(ctx, path, ref)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
if s.maxBytes > 0 && info != nil && int64(len(info.Data)) > s.maxBytes {
|
||||
return nil, apierrors.NewRequestEntityTooLargeError(
|
||||
fmt.Sprintf("file %q is %d bytes; max allowed is %d bytes", info.Path, len(info.Data), s.maxBytes),
|
||||
)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// withSizeLimit returns rw wrapped so its Read method enforces the connector's
|
||||
// configured max file size. Returns rw unchanged when the cap is disabled.
|
||||
func (c *filesConnector) withSizeLimit(rw repository.ReaderWriter) repository.ReaderWriter {
|
||||
if c.maxFileSize <= 0 {
|
||||
return rw
|
||||
}
|
||||
return &sizeLimitedReaderWriter{ReaderWriter: rw, maxBytes: c.maxFileSize}
|
||||
}
|
||||
|
||||
func (*filesConnector) New() runtime.Object {
|
||||
// This is added as the "ResponseType" regardless what ProducesObject() returns
|
||||
return &provisioning.ResourceWrapper{}
|
||||
@@ -105,6 +135,10 @@ func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http
|
||||
responder.Error(apierrors.NewBadRequest("repository does not support read-writing"))
|
||||
return
|
||||
}
|
||||
// Enforce max_file_size right at the repo boundary so oversized payloads
|
||||
// are rejected before parsing/DryRun runs in DualReadWriter.Read or before
|
||||
// the bytes are streamed back from handleGetRawFile.
|
||||
readWriter = c.withSizeLimit(readWriter)
|
||||
|
||||
dualReadWriter, authorizer, err := c.createDualReadWriter(ctx, repo, readWriter)
|
||||
if err != nil {
|
||||
@@ -310,7 +344,7 @@ func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts r
|
||||
return dualReadWriter.CreateFolder(ctx, opts)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
data, err := readBody(r, c.maxFileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -326,7 +360,7 @@ func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts r
|
||||
func (c *filesConnector) handleMove(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// For move operations, only read body for file moves (not directory moves)
|
||||
if !isDir {
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
data, err := readBody(r, c.maxFileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -348,7 +382,7 @@ func (c *filesConnector) handlePut(ctx context.Context, r *http.Request, opts re
|
||||
return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
data, err := readBody(r, c.maxFileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -362,7 +396,7 @@ func (c *filesConnector) handlePut(ctx context.Context, r *http.Request, opts re
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleFolderMetadataUpdate(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
data, err := readBody(r, c.maxFileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -536,6 +536,66 @@ func TestHandleGetRawFile_FolderScopedAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetRawFile_MaxFileSize(t *testing.T) {
|
||||
const path = "README.md"
|
||||
repo := &provisioningapi.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-repo"},
|
||||
Spec: provisioningapi.RepositorySpec{
|
||||
Sync: provisioningapi.SyncOptions{Target: provisioningapi.SyncTargetTypeFolder},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
maxFileSize int64
|
||||
dataSize int
|
||||
wantTooBig bool
|
||||
}{
|
||||
{name: "under limit", maxFileSize: 1024, dataSize: 512},
|
||||
{name: "exactly at limit", maxFileSize: 1024, dataSize: 1024},
|
||||
{name: "over limit", maxFileSize: 1024, dataSize: 2048, wantTooBig: true},
|
||||
{name: "zero disables limit", maxFileSize: 0, dataSize: 10 * 1024 * 1024},
|
||||
{name: "negative disables limit", maxFileSize: -1, dataSize: 10 * 1024 * 1024},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockReadWriter := repository.NewMockReaderWriter(t)
|
||||
mockAccess := auth.NewMockAccessChecker(t)
|
||||
mockAccess.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
|
||||
mockReadWriter.EXPECT().Config().Return(repo).Maybe()
|
||||
authorizer := resources.NewAuthorizer(repo, mockReadWriter, mockAccess, false)
|
||||
|
||||
mockReadWriter.EXPECT().Read(mock.Anything, path, "").Return(&repository.FileInfo{
|
||||
Path: path,
|
||||
Data: make([]byte, tc.dataSize),
|
||||
Ref: "main",
|
||||
}, nil)
|
||||
|
||||
connector := &filesConnector{access: mockAccess, maxFileSize: tc.maxFileSize}
|
||||
// handleRequest wraps readWriter with the size limiter at the
|
||||
// connector boundary. Mirror that here so the unit test exercises
|
||||
// the same enforcement point as production.
|
||||
limited := connector.withSizeLimit(mockReadWriter)
|
||||
|
||||
_, err := connector.handleGetRawFile(
|
||||
context.Background(),
|
||||
resources.DualWriteOptions{Path: path},
|
||||
limited,
|
||||
authorizer,
|
||||
)
|
||||
|
||||
if tc.wantTooBig {
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsRequestEntityTooLargeError(err), "expected 413 RequestEntityTooLarge, got %v", err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRawFileIntegration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -139,6 +139,7 @@ type APIBuilder struct {
|
||||
registry prometheus.Registerer
|
||||
quotaGetter quotas.QuotaGetter
|
||||
folderMetadataEnabled bool
|
||||
maxFileSize int64
|
||||
incrementalPolicy repository.IncrementalSyncPolicy
|
||||
folderAPIVersion string
|
||||
webhookSecretRotationInterval time.Duration
|
||||
@@ -241,6 +242,10 @@ func NewAPIBuilder(
|
||||
folderMetadataEnabled: folderMetadataEnabled,
|
||||
folderAPIVersion: folderAPIVersion,
|
||||
incrementalPolicy: incrementalPolicy,
|
||||
// Default cap for the files API. Callers (e.g. RegisterAPIService)
|
||||
// may overwrite b.maxFileSize after construction; any non-positive
|
||||
// value (<=0) disables the cap.
|
||||
maxFileSize: setting.ProvisioningMaxFileSizeDefault,
|
||||
}
|
||||
|
||||
for _, builder := range extraBuilders {
|
||||
@@ -315,6 +320,7 @@ func RegisterAPIService(
|
||||
jobHistoryConfig := createJobHistoryConfigFromSettings(cfg)
|
||||
folderMetadataEnabled := features.IsEnabledGlobally(featuremgmt.FlagProvisioningFolderMetadata) //nolint:staticcheck
|
||||
folderAPIVersion := cfg.ProvisioningFolderAPIVersion
|
||||
maxFileSize := cfg.ProvisioningMaxFileSize
|
||||
incrementalPolicy := repository.NewIncrementalSyncPolicy(folderMetadataEnabled, cfg.ProvisioningMaxIncrementalChanges)
|
||||
|
||||
// Register v0alpha1 (preferred version)
|
||||
@@ -353,6 +359,7 @@ func RegisterAPIService(
|
||||
return nil, err
|
||||
}
|
||||
builder.webhookSecretRotationInterval = cfg.ProvisioningWebhookSecretRotationInterval
|
||||
builder.maxFileSize = maxFileSize
|
||||
apiregistration.RegisterAPI(builder)
|
||||
|
||||
// Register v1beta1
|
||||
@@ -391,6 +398,7 @@ func RegisterAPIService(
|
||||
return nil, err
|
||||
}
|
||||
v1beta1Builder.webhookSecretRotationInterval = cfg.ProvisioningWebhookSecretRotationInterval
|
||||
v1beta1Builder.maxFileSize = maxFileSize
|
||||
apiregistration.RegisterAPI(v1beta1Builder)
|
||||
|
||||
// Return the preferred (v0alpha1) builder since it runs controllers/workers
|
||||
@@ -802,7 +810,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
// connector via ProvisioningAuthorizer.AuthorizeResource, and repository-
|
||||
// level operations remain Admin-gated by authorizeRepositorySubresource.
|
||||
filesAccess := auth.NewVerbAwareAccessChecker(b.accessWithViewer, b.accessWithEditor)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, filesAccess, b.folderMetadataEnabled, b.folderAPIVersion)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, filesAccess, b.folderMetadataEnabled, b.folderAPIVersion, b.maxFileSize)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
|
||||
getter: b,
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,14 +20,24 @@ const (
|
||||
errMsgRequestTooLarge = "request body too large"
|
||||
)
|
||||
|
||||
// readBody reads the request body and limits the size
|
||||
// readBody reads the request body and limits the size. A non-positive
|
||||
// maxSize disables the limit.
|
||||
func readBody(r *http.Request, maxSize int64) ([]byte, error) {
|
||||
if maxSize <= 0 {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading request body: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
limitedBody := http.MaxBytesReader(nil, r.Body, maxSize)
|
||||
body, err := io.ReadAll(limitedBody)
|
||||
if err != nil {
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) {
|
||||
return nil, fmt.Errorf("%s: max size %d bytes", errMsgRequestTooLarge, maxSize)
|
||||
return nil, apierrors.NewRequestEntityTooLargeError(
|
||||
fmt.Sprintf("%s: max size %d bytes", errMsgRequestTooLarge, maxSize),
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("error reading request body: %w", err)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ func TestReadBody(t *testing.T) {
|
||||
body: "1234567890",
|
||||
maxSize: 10,
|
||||
},
|
||||
{
|
||||
name: "zero maxSize disables the cap",
|
||||
body: "anything goes when the cap is unlimited",
|
||||
maxSize: 0,
|
||||
},
|
||||
{
|
||||
name: "negative maxSize disables the cap",
|
||||
body: "anything goes when the cap is unlimited",
|
||||
maxSize: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -65,6 +65,11 @@ const zoneInfo = "ZONEINFO"
|
||||
// Default renderer auth token from [rendering]renderer_token.
|
||||
const DefaultRendererAuthToken = "-"
|
||||
|
||||
// ProvisioningMaxFileSizeDefault is the default value for the
|
||||
// [provisioning] max_file_size key (5 MiB). It bounds files read from or
|
||||
// written to a provisioning repository through the files API.
|
||||
const ProvisioningMaxFileSizeDefault int64 = 5 * 1024 * 1024
|
||||
|
||||
var (
|
||||
customInitPath = "conf/custom.ini"
|
||||
|
||||
@@ -163,6 +168,7 @@ type Cfg struct {
|
||||
ProvisioningMaxRepositories int64 // default 10, 0 in config = unlimited (converted to -1 internally)
|
||||
ProvisioningFolderAPIVersion string // "v1" (default for on-prem) or "v1beta1"
|
||||
ProvisioningMaxIncrementalChanges int // default 100, 0 in config = unlimited
|
||||
ProvisioningMaxFileSize int64 // bytes; default 5 MiB (5242880); <=0 = unlimited
|
||||
ProvisioningWebhookSecretRotationInterval time.Duration // default 30 days
|
||||
ProvisioningPublicRootURL string // public-facing root URL of this Grafana instance for provisioning consumers (webhooks, screenshots); falls back to AppURL when empty
|
||||
DataPath string
|
||||
@@ -2469,6 +2475,7 @@ func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
|
||||
cfg.ProvisioningMaxRepositories = iniFile.Section("provisioning").Key("max_repositories").MustInt64(10)
|
||||
cfg.ProvisioningFolderAPIVersion = iniFile.Section("provisioning").Key("folders_api_version").MustString("v1")
|
||||
cfg.ProvisioningMaxIncrementalChanges = iniFile.Section("provisioning").Key("max_incremental_changes").MustInt(100)
|
||||
cfg.ProvisioningMaxFileSize = iniFile.Section("provisioning").Key("max_file_size").MustInt64(ProvisioningMaxFileSizeDefault)
|
||||
cfg.ProvisioningWebhookSecretRotationInterval = iniFile.Section("provisioning").Key("webhook_secret_rotation_interval").MustDuration(30 * 24 * time.Hour)
|
||||
cfg.ProvisioningPublicRootURL = strings.TrimRight(valueAsString(iniFile.Section("provisioning"), "public_root_url", ""), "/")
|
||||
|
||||
|
||||
@@ -1215,6 +1215,17 @@ func WithProvisioningMaxIncrementalChanges(n int) GrafanaOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithProvisioningMaxFileSize overrides the per-file size cap enforced by the
|
||||
// files API on both reads and writes. A small value (e.g. 1024) keeps the
|
||||
// fixture cheap to generate; any non-positive value (<=0) disables the check
|
||||
// entirely. Pass an int64 — the helper takes its address so GrafanaOpts can
|
||||
// distinguish "not set" (nil) from an explicit 0.
|
||||
func WithProvisioningMaxFileSize(n int64) GrafanaOption {
|
||||
return func(opts *testinfra.GrafanaOpts) {
|
||||
opts.ProvisioningMaxFileSize = &n
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutExportFeatureFlag disables the provisioningExport feature flag.
|
||||
func WithoutExportFeatureFlag(opts *testinfra.GrafanaOpts) {
|
||||
// Remove provisioningExport from the enabled feature toggles
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package maxfilesize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tests/apis/provisioning/common"
|
||||
)
|
||||
|
||||
// TestIntegrationProvisioning_MaxFileSize_RawRead exercises the read-side
|
||||
// enforcement of [provisioning] max_file_size on raw files (e.g. README.md).
|
||||
// A file under the configured cap is served as-is; a file over the cap is
|
||||
// rejected with HTTP 413 Request Entity Too Large.
|
||||
func TestIntegrationProvisioning_MaxFileSize_RawRead(t *testing.T) {
|
||||
helper := sharedHelper(t)
|
||||
|
||||
const repo = "max-file-size-raw-read"
|
||||
helper.CreateLocalRepo(t, common.TestRepo{
|
||||
Name: repo,
|
||||
LocalPath: helper.ProvisioningPath,
|
||||
SyncTarget: "instance",
|
||||
Workflows: []string{"write"},
|
||||
SkipResourceAssertions: true,
|
||||
})
|
||||
|
||||
smallReadme := []byte("# small README\n")
|
||||
largeReadme := bytes.Repeat([]byte("X"), int(testMaxFileSize)+1)
|
||||
|
||||
helper.WriteToProvisioningPath(t, "README.md", smallReadme)
|
||||
helper.WriteToProvisioningPath(t, "huge/README.md", largeReadme)
|
||||
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
|
||||
t.Run("GET small raw file under limit succeeds", func(t *testing.T) {
|
||||
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)
|
||||
// nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "small README should be served")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var wrapper struct {
|
||||
Resource struct {
|
||||
File struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"file"`
|
||||
} `json:"resource"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &wrapper))
|
||||
require.Equal(t, string(smallReadme), wrapper.Resource.File.Content,
|
||||
"under-limit raw file should be served verbatim")
|
||||
})
|
||||
|
||||
t.Run("GET raw file over limit returns 413", func(t *testing.T) {
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/huge/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)
|
||||
// nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "oversized README should be rejected with 413")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), "max allowed", "error body should advertise the cap")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIntegrationProvisioning_MaxFileSize_Write exercises the write-side
|
||||
// enforcement: a POST whose body exceeds [provisioning] max_file_size is
|
||||
// rejected before the resource is parsed or persisted.
|
||||
func TestIntegrationProvisioning_MaxFileSize_Write(t *testing.T) {
|
||||
helper := sharedHelper(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "max-file-size-write"
|
||||
helper.CreateLocalRepo(t, common.TestRepo{
|
||||
Name: repo,
|
||||
LocalPath: helper.ProvisioningPath,
|
||||
SyncTarget: "instance",
|
||||
Workflows: []string{"write"},
|
||||
SkipResourceAssertions: true,
|
||||
})
|
||||
|
||||
// A minimal but well-formed dashboard JSON, padded to exceed the cap.
|
||||
// Padding lives inside the title field so the body is still valid JSON.
|
||||
pad := strings.Repeat("p", int(testMaxFileSize)+1)
|
||||
oversized, err := json.Marshal(map[string]any{
|
||||
"apiVersion": "dashboard.grafana.app/v1beta1",
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{"name": "huge"},
|
||||
"spec": map[string]any{"title": pad},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(oversized), int(testMaxFileSize), "fixture must exceed the configured cap")
|
||||
|
||||
var statusCode int
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "huge.json").
|
||||
Body(oversized).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx).StatusCode(&statusCode)
|
||||
|
||||
require.Error(t, result.Error(), "oversized POST should be rejected")
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, statusCode,
|
||||
"oversized POST should return HTTP 413; got %v", result.Error())
|
||||
require.Contains(t, result.Error().Error(), "request body too large",
|
||||
"error should advertise the size cap; got %v", result.Error())
|
||||
}
|
||||
|
||||
// The "0 = unlimited" semantics are covered by the unit test
|
||||
// TestHandleGetRawFile_MaxFileSize/zero_disables_limit. We deliberately do
|
||||
// not exercise it as a separate integration test here: spinning up a second
|
||||
// Grafana inside this package would share the package-wide test database
|
||||
// with the shared env, and the shared env's controllers would then attempt
|
||||
// to validate the second server's repos against their own (different)
|
||||
// permitted_provisioning_paths and fail the sync.
|
||||
@@ -0,0 +1,26 @@
|
||||
package maxfilesize
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tests/apis/provisioning/common"
|
||||
)
|
||||
|
||||
// testMaxFileSize is the per-file cap installed in the shared test server.
|
||||
// Small enough that fixtures stay cheap; large enough that ordinary
|
||||
// fixtures (text-options.json etc.) still fit comfortably under it.
|
||||
const testMaxFileSize int64 = 4 * 1024
|
||||
|
||||
var env = common.NewSharedEnv(
|
||||
common.WithoutProvisioningFolderMetadata,
|
||||
common.WithProvisioningMaxFileSize(testMaxFileSize),
|
||||
)
|
||||
|
||||
func sharedHelper(t *testing.T) *common.ProvisioningTestHelper {
|
||||
t.Helper()
|
||||
return env.GetCleanHelper(t)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
env.RunTestMain(m)
|
||||
}
|
||||
@@ -800,6 +800,14 @@ func createGrafDir(t *testing.T, tmpDir string, opts GrafanaOpts) (string, strin
|
||||
_, err = provisioningSect.NewKey("max_incremental_changes", fmt.Sprintf("%d", *opts.ProvisioningMaxIncrementalChanges))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// nil means "use the ini default" (5 MB). Non-nil writes the value,
|
||||
// including 0 which disables the per-file size check.
|
||||
if opts.ProvisioningMaxFileSize != nil {
|
||||
provisioningSect, err := getOrCreateSection("provisioning")
|
||||
require.NoError(t, err)
|
||||
_, err = provisioningSect.NewKey("max_file_size", fmt.Sprintf("%d", *opts.ProvisioningMaxFileSize))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
if opts.EnableSCIM {
|
||||
scimSection, err := getOrCreateSection("auth.scim")
|
||||
require.NoError(t, err)
|
||||
@@ -962,6 +970,7 @@ type GrafanaOpts struct {
|
||||
ProvisioningMaxRepositories int64
|
||||
ProvisioningFolderAPIVersion string
|
||||
ProvisioningMaxIncrementalChanges *int
|
||||
ProvisioningMaxFileSize *int64
|
||||
GrafanaComSSOAPIToken string
|
||||
LicensePath string
|
||||
EnableRecordingRules bool
|
||||
|
||||
Reference in New Issue
Block a user