Provisioning: return field paths in test error messages (#103850)

* Provisioning: Do not block connect step on error

* Display field errors

* Cleanup

* return field errors

* fix test

* convert errros to an array

* Fix history display

* Add getFormErrors

* metav1 issues

* lint

* Proper field names

* Fix notification

* Remove unused component

---------

Co-authored-by: Clarity-89 <homes89@ukr.net>
This commit is contained in:
Ryan McKinley
2025-04-11 14:26:42 +01:00
committed by GitHub
co-authored by Clarity-89
parent ed9a7e8d9f
commit 2c3422fc5c
21 changed files with 260 additions and 245 deletions
+7 -4
View File
@@ -394,11 +394,14 @@ type TestResults struct {
// Is the connection healthy
Success bool `json:"success"`
// Error descriptions
Errors []string `json:"errors,omitempty"`
// Field related errors
Errors []ErrorDetails `json:"errors,omitempty"`
}
// Optional details
Details *common.Unstructured `json:"details,omitempty"`
type ErrorDetails struct {
Type metav1.CauseType `json:"type"`
Field string `json:"field,omitempty"`
Detail string `json:"detail,omitempty"`
}
// HistoryList is a list of versions of a resource
@@ -27,6 +27,22 @@ func (in *Author) DeepCopy() *Author {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ErrorDetails) DeepCopyInto(out *ErrorDetails) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorDetails.
func (in *ErrorDetails) DeepCopy() *ErrorDetails {
if in == nil {
return nil
}
out := new(ErrorDetails)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) {
*out = *in
@@ -849,13 +865,9 @@ func (in *TestResults) DeepCopyInto(out *TestResults) {
out.TypeMeta = in.TypeMeta
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = make([]string, len(*in))
*out = make([]ErrorDetails, len(*in))
copy(*out, *in)
}
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = (*in).DeepCopy()
}
return
}
@@ -15,6 +15,7 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref),
@@ -88,6 +89,38 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback)
}
}
func schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"field": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"detail": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"type"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -1909,31 +1942,24 @@ func schema_pkg_apis_provisioning_v0alpha1_TestResults(ref common.ReferenceCallb
},
"errors": {
SchemaProps: spec.SchemaProps{
Description: "Error descriptions",
Description: "Field related errors",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"),
},
},
},
},
},
"details": {
SchemaProps: spec.SchemaProps{
Description: "Optional details",
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
},
Required: []string{"code", "success"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ErrorDetails"},
}
}
@@ -271,18 +271,22 @@ func (rc *RepositoryController) runHealthCheck(ctx context.Context, repo reposit
if err != nil {
res = &provisioning.TestResults{
Success: false,
Errors: []string{
"error running test repository",
err.Error(),
},
Errors: []provisioning.ErrorDetails{{
Detail: fmt.Sprintf("error running test repository: %s", err.Error()),
}},
}
}
healthStatus := provisioning.HealthStatus{
Healthy: res.Success,
Checked: time.Now().UnixMilli(),
Message: res.Errors,
}
for _, err := range res.Errors {
if err.Detail != "" {
healthStatus.Message = append(healthStatus.Message, err.Detail)
}
}
logger.Info("health check completed", "status", healthStatus)
return healthStatus
+1 -1
View File
@@ -1114,7 +1114,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets)
}
return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn)
return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL, cloneFn), nil
default:
return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
}
@@ -12,12 +12,11 @@ import (
"strings"
"github.com/google/go-github/v70/github"
"github.com/google/uuid"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"github.com/google/uuid"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
@@ -57,18 +56,14 @@ func NewGitHub(
secrets secrets.Service,
webhookURL string,
cloneFn CloneFn,
) (*githubRepository, error) {
owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL)
if err != nil {
return nil, err
}
) *githubRepository {
owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL)
token := config.Spec.GitHub.Token
if token == "" {
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil {
return nil, err
if err == nil {
token = string(decrypted)
}
token = string(decrypted)
}
return &githubRepository{
config: config,
@@ -78,7 +73,7 @@ func NewGitHub(
owner: owner,
repo: repo,
cloneFn: cloneFn,
}, nil
}
}
func (r *githubRepository) Config() *provisioning.Repository {
@@ -138,59 +133,48 @@ func parseOwnerRepo(giturl string) (owner string, repo string, err error) {
return parts[1], parts[2], nil
}
func fromError(err error, code int) *provisioning.TestResults {
statusErr, ok := err.(apierrors.APIStatus)
if ok {
s := statusErr.Status()
return &provisioning.TestResults{
Code: int(s.Code),
Success: false,
Errors: []string{s.Message},
}
}
return &provisioning.TestResults{
Code: code,
Success: false,
Errors: []string{err.Error()},
}
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
if err := r.gh.IsAuthenticated(ctx); err != nil {
return fromError(err, http.StatusUnauthorized), nil
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "github", "token").String(),
Detail: err.Error(),
}}}, nil
}
owner, repo, err := parseOwnerRepo(r.config.Spec.GitHub.URL)
url := r.config.Spec.GitHub.URL
owner, repo, err := parseOwnerRepo(url)
if err != nil {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
// FIXME: check token permissions
ok, err := r.gh.RepoExists(ctx, owner, repo)
if err != nil {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"repository does not exist"},
}, nil
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "url"), url)), nil
}
ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, r.config.Spec.GitHub.Branch)
branch := r.config.Spec.GitHub.Branch
ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, branch)
if err != nil {
return fromError(err, http.StatusBadRequest), nil
return fromFieldError(field.Invalid(
field.NewPath("spec", "github", "branch"), branch, err.Error())), nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"branch does not exist"},
}, nil
return fromFieldError(field.NotFound(
field.NewPath("spec", "github", "branch"), branch)), nil
}
return &provisioning.TestResults{
@@ -146,36 +146,19 @@ func (r *localRepository) Validate() (fields field.ErrorList) {
// Test implements provisioning.Repository.
// NOTE: Validate has been called (and passed) before this function should be called
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
path := field.NewPath("spec", "localhost", "path")
if r.config.Spec.Local.Path == "" {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
"no path is configured",
},
}, nil
return fromFieldError(field.Required(path, "no path is configured")), nil
}
_, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
if err != nil {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
err.Error(),
},
}, nil
return fromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
}
_, err = os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
fmt.Sprintf("directory not found: %s", r.config.Spec.Local.Path),
},
}, nil
return fromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
}
return &provisioning.TestResults{
@@ -6,6 +6,7 @@ import (
"net/http"
"slices"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
@@ -26,10 +27,14 @@ func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestRes
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid
Success: false,
Errors: make([]string, len(errors)),
Errors: make([]provisioning.ErrorDetails, len(errors)),
}
for i, v := range errors {
rsp.Errors[i] = v.Error()
for i, err := range errors {
rsp.Errors[i] = provisioning.ErrorDetails{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}
}
return rsp, nil
}
@@ -85,3 +90,15 @@ func ValidateRepository(repo Repository) field.ErrorList {
return list
}
func fromFieldError(err *field.Error) *provisioning.TestResults {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseType(err.Type),
Field: err.Field,
Detail: err.Detail,
}},
}
}
@@ -6,11 +6,12 @@ import (
"net/http"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
func TestValidateRepository(t *testing.T) {
@@ -192,7 +193,7 @@ func TestTestRepository(t *testing.T) {
name string
repository *MockRepository
expectedCode int
expectedErrs []string
expectedErrs []provisioning.ErrorDetails
expectedError error
}{
{
@@ -208,7 +209,11 @@ func TestTestRepository(t *testing.T) {
return m
}(),
expectedCode: http.StatusUnprocessableEntity,
expectedErrs: []string{"spec.title: Required value: a repository title must be given"},
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueRequired,
Field: "spec.title",
Detail: "a repository title must be given",
}},
},
{
name: "test passes",
@@ -257,12 +262,18 @@ func TestTestRepository(t *testing.T) {
m.On("Test", mock.Anything).Return(&provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"test failed"},
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
}, nil)
return m
}(),
expectedCode: http.StatusBadRequest,
expectedErrs: []string{"test failed"},
expectedErrs: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: "spec.property",
}},
},
}
+8 -4
View File
@@ -99,9 +99,9 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio
if res == nil {
res = &provisioning.TestResults{
Success: false,
Errors: []string{
"missing health status",
},
Errors: []provisioning.ErrorDetails{{
Detail: "missing health status",
}},
}
}
@@ -109,7 +109,11 @@ func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisio
repo.Status.Health = provisioning.HealthStatus{
Healthy: res.Success,
Checked: time.Now().UnixMilli(),
Message: res.Errors,
}
for _, err := range res.Errors {
if err.Detail != "" {
repo.Status.Health.Message = append(repo.Status.Health.Message, err.Detail)
}
}
_, err := t.client.Repositories(repo.GetNamespace()).
@@ -2472,6 +2472,24 @@
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails": {
"type": "object",
"required": [
"type"
],
"properties": {
"detail": {
"type": "string"
},
"field": {
"type": "string"
},
"type": {
"type": "string",
"default": ""
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": {
"type": "object",
"properties": {
@@ -3762,20 +3780,16 @@
"format": "int32",
"default": 0
},
"details": {
"description": "Optional details",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured"
}
]
},
"errors": {
"description": "Error descriptions",
"description": "Field related errors",
"type": "array",
"items": {
"type": "string",
"default": ""
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails"
}
]
}
},
"kind": {