Repository: grafana/grafana
Pull request #103265: Provisioning: Run validation on config updates
Author: login: MissingRoberto
Labels: name: area/backend
name: area/frontend
name: no-backport
description: Skip backport of PR
name: no-changelog
description: Skip including change in changelog/release notes
Created: 2025-04-02T10:00:06Z | Merged: 2025-04-07T11:48:29Z

<!--

Thank you for sending a pull request! Here are some tips:

1. If this is your first time, please read our contribution guide at https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md

2. Ensure you include and run the appropriate tests as part of your Pull Request.

3. In a new feature or configuration option, an update to the documentation is necessary. Everything related to the documentation is under the docs folder in the root of the repository.

4. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.

5. If you can not merge your Pull Request due to a merge conflict, Rebase it. This gets it in sync with the main branch.

6. Name your PR as "<FeatureArea>: Describe your change", e.g. Alerting: Prevent race condition. If it's a fix or feature relevant for the changelog describe the user impact in the title. The PR title is used to auto-generate the changelog for issues marked with the "add to changelog" label.

7. If your PR content should be added to the What's New document for the next major or minor release, add the **add to what's new** label to your PR. Note that you should add this label to the main PR that introduces the feature; do not add this label to smaller PRs for the feature.

-->

**What is this feature?**

Run validation on repository config updates.

**Why do we need this feature?**

We want to synchronously fail if the user tries to update a repository with invalid configuration.

**Who is this feature for?**

Users of Git Sync.

**Which issue(s) does this PR fix?**:

<!--

- Automatically closes linked issue when the Pull Request is merged.

Usage: "Fixes #<issue number>", or "Fixes (paste link of issue)"

-->

Fixes <https://github.com/grafana/git-ui-sync-project/issues/176>

**Special notes for your reviewer:**

Please check that:

- [x] It works as expected from a user's perspective.
- [x] If this is a pre-GA feature, it is behind a feature toggle.
- [ ] The docs are updated, and if this is a [notable improvement](https://grafana.com/docs/writers-toolkit/contribute/release-notes/#how-to-determine-if-content-belongs-in-whats-new), it's added to our [What's New](https://grafana.com/docs/writers-toolkit/contribute/release-notes/) doc.



Diff:
diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go
index 096768ca0376f..5e46f499246d1 100644
--- a/pkg/registry/apis/provisioning/register.go
+++ b/pkg/registry/apis/provisioning/register.go
@@ -478,36 +478,26 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
 		}
 	}
 
-	targetError := b.verifyAgaintsExistingRepositories(cfg)
-	if targetError != nil {
-		list = append(list, targetError)
+	// Early exit to avoid more expensive checks if we have already found errors
+	if len(list) > 0 {
+		return invalidRepositoryError(a.GetName(), list)
 	}
 
-	// For *create* we do a synchronous test... this can be expensive!
-	// it is the same as a full healthcheck, so should not be run on every update
-	if len(list) == 0 && a.GetOperation() == admission.Create {
-		testResults, err := repository.TestRepository(ctx, repo)
-		if err != nil {
-			list = append(list, field.Invalid(field.NewPath("spec"),
-				"Repository test failed", "Unable to verify repository: "+err.Error()))
-		}
-
-		if !testResults.Success {
-			for _, err := range testResults.Errors {
-				list = append(list, field.Invalid(field.NewPath("spec"),
-					"Repository test failed", err))
-			}
-		}
+	// Exit early if we have already found errors
+	targetError := b.verifyAgaintsExistingRepositories(cfg)
+	if targetError != nil {
+		return invalidRepositoryError(a.GetName(), field.ErrorList{targetError})
 	}
 
-	if len(list) > 0 {
-		return apierrors.NewInvalid(
-			provisioning.RepositoryResourceInfo.GroupVersionKind().GroupKind(),
-			a.GetName(), list)
-	}
 	return nil
 }
 
+func invalidRepositoryError(name string, list field.ErrorList) error {
+	return apierrors.NewInvalid(
+		provisioning.RepositoryResourceInfo.GroupVersionKind().GroupKind(),
+		name, list)
+}
+
 // TODO: move this to a more appropriate place. Probably controller/validation.go
 func (b *APIBuilder) verifyAgaintsExistingRepositories(cfg *provisioning.Repository) *field.Error {
 	all, err := b.repositoryLister.Repositories(cfg.Namespace).List(labels.Everything())
diff --git a/public/app/api/clients/provisioning/index.ts b/public/app/api/clients/provisioning/index.ts
index 109b384710298..3aeb73d552f99 100644
--- a/public/app/api/clients/provisioning/index.ts
+++ b/public/app/api/clients/provisioning/index.ts
@@ -1,3 +1,4 @@
+import { isFetchError } from '@grafana/runtime';
 import { notifyApp } from 'app/core/actions';
 import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification';
 import { t } from 'app/core/internationalization';
@@ -93,8 +94,16 @@ export const provisioningAPI = generatedAPI.enhanceEndpoints({
         try {
           await queryFulfilled;
         } catch (e) {
-          if (e instanceof Error) {
-            dispatch(notifyApp(createErrorNotification('Error testing repository', e)));
+          if (!e) {
+            dispatch(notifyApp(createErrorNotification('Error validating repository', new Error('Unknown error'))));
+          } else if (e instanceof Error) {
+            dispatch(notifyApp(createErrorNotification('Error validating repository', e)));
+          } else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) {
+            if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
+              dispatch(
+                notifyApp(createErrorNotification('Error validating repository', e.error.data.errors.join('\n')))
+              );
+            }
           }
         }
       },
diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx
index 878314f201ef7..025b728001aed 100644
--- a/public/app/features/provisioning/Wizard/ConnectStep.tsx
+++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx
@@ -41,15 +41,22 @@ export function ConnectStep() {
           'Choose the type of storage for your resources'
         )}
       >
-        <Combobox
-          options={typeOptions}
-          value={type}
-          onChange={(value) => {
-            const repoType = value?.value;
-            setValue('repository.type', repoType);
-            setValue(
-              'repository.workflows',
-              getWorkflowOptions(repoType).map((v) => v.value)
+        <Controller
+          name={'repository.type'}
+          render={({ field: { ref, onChange, ...field } }) => {
+            return (
+              <Combobox
+                options={typeOptions}
+                onChange={(value) => {
+                  const repoType = value?.value;
+                  onChange(repoType);
+                  setValue(
+                    'repository.workflows',
+                    getWorkflowOptions(repoType).map((v) => v.value)
+                  );
+                }}
+                {...field}
+              />
             );
           }}
         />
diff --git a/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
index f91c4f0cdcaac..a7d78c1385d6e 100644
--- a/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
+++ b/public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
@@ -3,15 +3,28 @@ import { useCallback } from 'react';
 import {
   RepositorySpec,
   useCreateRepositoryMutation,
+  useCreateRepositoryTestMutation,
   useReplaceRepositoryMutation,
 } from 'app/api/clients/provisioning';
 
 export function useCreateOrUpdateRepository(name?: string) {
   const [create, createRequest] = useCreateRepositoryMutation();
   const [update, updateRequest] = useReplaceRepositoryMutation();
+  const [testConfig, testRequest] = useCreateRepositoryTestMutation();
 
   const updateOrCreate = useCallback(
-    (data: RepositorySpec) => {
+    async (data: RepositorySpec) => {
+      // First test the config and wait for the result
+      // unwrap will throw an error if the test fails
+      await testConfig({
+        // HACK: we need to provide a name to the test configuration
+        name: name || 'new',
+        body: {
+          spec: data,
+        },
+      }).unwrap();
+
+      // If test passes, proceed with create/update
       if (name) {
         return update({
           name,
@@ -28,10 +41,10 @@ export function useCreateOrUpdateRepository(name?: string) {
       }
       return create({ repository: { metadata: generateRepositoryMetadata(data), spec: data } });
     },
-    [create, name, update]
+    [create, name, update, testConfig]
   );
 
-  return [updateOrCreate, name ? updateRequest : createRequest] as const;
+  return [updateOrCreate, name ? updateRequest : createRequest, testRequest] as const;
 }
 
 const generateRepositoryMetadata = (data: RepositorySpec) => {
diff --git a/public/app/features/provisioning/utils/data.ts b/public/app/features/provisioning/utils/data.ts
index 1abd1f879e932..e3017d7446e93 100644
--- a/public/app/features/provisioning/utils/data.ts
+++ b/public/app/features/provisioning/utils/data.ts
@@ -27,16 +27,17 @@ export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
       break;
   }
 
-  return spec;
+  // We need to deep clone the data, so it doesn't become immutable
+  return structuredClone(spec);
 };
 
 export const specToData = (spec: RepositorySpec): RepositoryFormData => {
-  return {
+  return structuredClone({
     ...spec,
     ...spec.github,
     ...spec.local,
     branch: spec.github?.branch || '',
     url: spec.github?.url || '',
     generateDashboardPreviews: spec.github?.generateDashboardPreviews || false,
-  };
+  });
 };


Reviews:
user: login: ryantxu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:45:26Z
user: login: MissingRoberto
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:21:17Z
user: login: Clarity-89
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T14:34:40Z
user: login: MissingRoberto
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:01:17Z
user: login: ryantxu
body: nice -- thanks
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T05:56:21Z

Review comments:
diff_hunk: @@ -477,36 +477,68 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
 		}
 	}
 
+	// Early exit to avoid more expensive checks if we have already found errors
+	if len(list) > 0 {
+		return invalidRepositoryError(a.GetName(), list)
+	}
+
+	// Exit early if we have already found errors
 	targetError := b.verifyAgaintsExistingRepositories(cfg)
 	if targetError != nil {
-		list = append(list, targetError)
+		return invalidRepositoryError(a.GetName(), field.ErrorList{targetError})
 	}
 
 	// For *create* we do a synchronous test... this can be expensive!
-	// it is the same as a full healthcheck, so should not be run on every update
-	if len(list) == 0 && a.GetOperation() == admission.Create {
-		testResults, err := repository.TestRepository(ctx, repo)
+	// For *update* we only do a test if the generation has changed
+	var shouldTest bool
+	switch a.GetOperation() {
+	case admission.Delete, admission.Connect:
+	case admission.Create:
+		shouldTest = true
+	case admission.Update:
+		oldRepo, err := b.asRepository(ctx, a.GetObject())
 		if err != nil {
-			list = append(list, field.Invalid(field.NewPath("spec"),
-				"Repository test failed", "Unable to verify repository: "+err.Error()))
+			return fmt.Errorf("get old repository for update: %w", err)
 		}
+		oldCfg := oldRepo.Config()
 
-		if !testResults.Success {
-			for _, err := range testResults.Errors {
-				list = append(list, field.Invalid(field.NewPath("spec"),
-					"Repository test failed", err))
-			}
-		}
+		shouldTest = cfg.Generation != oldCfg.Generation
path: pkg/registry/apis/provisioning/register.go
user: login: ryantxu
body: pretty sure this won't work as expected -- the generation increment happens *after* this call so this would be executed on the following request (likely soon.... given how often this method gets invoked!)
created_at: 2025-04-02T11:45:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,37 +1,78 @@
 import { useCallback } from 'react';
 
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
 import {
   RepositorySpec,
   useCreateRepositoryMutation,
+  useCreateRepositoryTestMutation,
   useReplaceRepositoryMutation,
 } from 'app/api/clients/provisioning';
 
 export function useCreateOrUpdateRepository(name?: string) {
   const [create, createRequest] = useCreateRepositoryMutation();
   const [update, updateRequest] = useReplaceRepositoryMutation();
+  const [testConfig, testRequest] = useCreateRepositoryTestMutation();
+  const appEvents = getAppEvents();
 
   const updateOrCreate = useCallback(
-    (data: RepositorySpec) => {
-      if (name) {
-        return update({
-          name,
-          repository: {
-            metadata: {
-              name,
-              // TODO? -- replace with patch spec, so the rest of the metadata is not replaced?
-              // Can that support optimistic locking? (eg, make sure the RV is the same?)
-              finalizers: ['cleanup', 'remove-orphan-resources'],
-            },
+    async (data: RepositorySpec) => {
path: public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
user: login: MissingRoberto
body: @Clarity-89 not sure if the way I wrote this code is the best. 
created_at: 2025-04-02T13:21:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,37 +1,78 @@
 import { useCallback } from 'react';
 
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
 import {
   RepositorySpec,
   useCreateRepositoryMutation,
+  useCreateRepositoryTestMutation,
   useReplaceRepositoryMutation,
 } from 'app/api/clients/provisioning';
 
 export function useCreateOrUpdateRepository(name?: string) {
   const [create, createRequest] = useCreateRepositoryMutation();
   const [update, updateRequest] = useReplaceRepositoryMutation();
+  const [testConfig, testRequest] = useCreateRepositoryTestMutation();
+  const appEvents = getAppEvents();
 
   const updateOrCreate = useCallback(
-    (data: RepositorySpec) => {
-      if (name) {
-        return update({
-          name,
-          repository: {
-            metadata: {
-              name,
-              // TODO? -- replace with patch spec, so the rest of the metadata is not replaced?
-              // Can that support optimistic locking? (eg, make sure the RV is the same?)
-              finalizers: ['cleanup', 'remove-orphan-resources'],
-            },
+    async (data: RepositorySpec) => {
+      try {
+        // First test the config
+        const testResult = await testConfig({
+          // HACK: we need to provide a name to the test configuration
+          name: name || 'new',
+          body: {
             spec: data,
           },
+        }).unwrap();
+
+        if (!testResult.success) {
+          appEvents.publish({
+            type: AppEvents.alertError.name,
+            payload: ['Repository configuration test failed', testResult.errors?.join(', ')],
+          });
+          return;
path: public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
user: login: Clarity-89
body: I'm a bit hesitant to emit these events here, I think it'd be done in places where this hook is consumed. The same goes for the error handling. 
created_at: 2025-04-02T14:34:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,37 +1,78 @@
 import { useCallback } from 'react';
 
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
 import {
   RepositorySpec,
   useCreateRepositoryMutation,
+  useCreateRepositoryTestMutation,
   useReplaceRepositoryMutation,
 } from 'app/api/clients/provisioning';
 
 export function useCreateOrUpdateRepository(name?: string) {
   const [create, createRequest] = useCreateRepositoryMutation();
   const [update, updateRequest] = useReplaceRepositoryMutation();
+  const [testConfig, testRequest] = useCreateRepositoryTestMutation();
+  const appEvents = getAppEvents();
 
   const updateOrCreate = useCallback(
-    (data: RepositorySpec) => {
-      if (name) {
-        return update({
-          name,
-          repository: {
-            metadata: {
-              name,
-              // TODO? -- replace with patch spec, so the rest of the metadata is not replaced?
-              // Can that support optimistic locking? (eg, make sure the RV is the same?)
-              finalizers: ['cleanup', 'remove-orphan-resources'],
-            },
+    async (data: RepositorySpec) => {
+      try {
+        // First test the config
+        const testResult = await testConfig({
+          // HACK: we need to provide a name to the test configuration
+          name: name || 'new',
+          body: {
             spec: data,
           },
+        }).unwrap();
+
+        if (!testResult.success) {
+          appEvents.publish({
+            type: AppEvents.alertError.name,
+            payload: ['Repository configuration test failed', testResult.errors?.join(', ')],
+          });
+          return;
path: public/app/features/provisioning/hooks/useCreateOrUpdateRepository.ts
user: login: MissingRoberto
body: let's then hold this PR till this one is merged: https://github.com/grafana/grafana/pull/103290/files#diff-47b52331a48eb9157cf74fade15880d10a9cab802b92bb20c528ec3b28eabcb3
created_at: 2025-04-02T15:01:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: ryantxu
created_at: 2025-04-02T11:12:42Z
body: hymmm -- I am still philosophically opposed to this.  It breaks with all normal k8s models and is a strong anti-pattern

We added the sync test (not validation!) to create as a quick hack to simplify/speed the wizard creation; but it is really not a patten we want.  Writing results should not depend on the state of the github api/network etc
author_association: CONTRIBUTOR
user: login: ryantxu
created_at: 2025-04-02T11:13:29Z
body: I would argue we should remove the test call from CREATE, and figure out the complexity in the UI -- since that is the more accurate/consistent API
author_association: CONTRIBUTOR
user: login: MissingRoberto
created_at: 2025-04-02T12:32:42Z
body: Thought about it more. You are philosophically correct. I will change this again to work better with the `/test` endpoint from the frontend. 
author_association: CONTRIBUTOR
user: login: MissingRoberto
created_at: 2025-04-02T13:19:34Z
body: Changed the approach to use the test endpoint in the `createOrUpdate` hook in the frontend. 
author_association: CONTRIBUTOR
user: login: MissingRoberto
created_at: 2025-04-02T14:10:47Z
body: main must be broken. I didn't touch that file. 
author_association: CONTRIBUTOR
user: login: MissingRoberto
created_at: 2025-04-04T10:33:21Z
body: assigned to @Clarity-89 to see how to better handle this between components 
author_association: CONTRIBUTOR
user: login: MissingRoberto
created_at: 2025-04-04T11:09:49Z
body: when configuration is valid and I click next this error happens: 


![image](https://github.com/user-attachments/assets/50d60754-20ea-4c74-8cf4-cca66900f899)

```
[Skip to main content](http://localhost:3000/#pageContent)

Grafana
Page error
Search or jump to...
⌘+k



User avatar
An unexpected error happened
Details
TypeError: Cannot assign to read only property 'target' of object '#<Object>'

    at BootstrapStep (http://localhost:3000/public/build/ProvisioningWizardPage.6bceb170a98193e17555.js:1123:26)
    at div
    at form
    at WizardContent (http://localhost:3000/public/build/ProvisioningWizardPage.6bceb170a98193e17555.js:2179:3)
    at FormProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:679993:13)
    at ProvisioningWizard (http://localhost:3000/public/build/ProvisioningWizardPage.6bceb170a98193e17555.js:1804:86)
    at PageContents (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:183365:25)
    at div
    at div
    at NativeScrollbar (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:180470:28)
    at div
    at Page (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:183246:3)
    at ConnectPage
    at Suspense
    at ErrorBoundary (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:139182:5)
    at GrafanaRoute (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:187856:102)
    at GrafanaRouteWrapper (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:187883:32)
    at RenderedRoute (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:516878:5)
    at Routes (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:517579:5)
    at div
    at http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:144778:5
    at main
    at div
    at div
    at div
    at AppChrome (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:175962:22)
    at ModalsContextProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:186347:76)
    at ExtraProviders (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:350103:16)
    at QueriesDrawerContextProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:279568:41)
    at RenderedRoute (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:516878:5)
    at Routes (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:517579:5)
    at Router (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:517513:15)
    at CompatRouter (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:515391:5)
    at LocationServiceProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:120777:3)
    at Router (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:519404:30)
    at RouterWrapper (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:350108:912)
    at div
    at ExtensionRegistriesProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:297855:3)
    at ScopesContextProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:306318:34)
    at KBarProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:375655:48)
    at CacheProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:684028:26)
    at SkeletonTheme (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:684146:26)
    at ThemeProvider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:191438:26)
    at ErrorBoundary (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:139182:5)
    at ErrorBoundaryAlert (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:139223:1)
    at Provider (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:685049:11)
    at AppWrapper (http://localhost:3000/public/build/app.16563eb47b8ce6c869db.js:173692:5)
```
author_association: CONTRIBUTOR