Date: Thu, 10 Jul 2025 02:40:26 -0400
Subject: [PATCH 02/33] NewProvisionedFolderForm: Preview folder name message
(#107739)
* NewProvisionedFolderForm: pass in empty title for new folder form
* NewProvisionedFolderForm: preview folder name
* i18n, fix test
* Added test
* added todo
* PR comment
* i18n
---
.../components/NewFolderForm.tsx | 25 ++--
.../components/NewProvisionedFolderForm.tsx | 66 +++++++---
.../components/utils.test.ts | 124 ++++++++++++++++++
.../browse-dashboards/components/utils.ts | 35 +++++
public/locales/en-US/grafana.json | 2 +-
5 files changed, 219 insertions(+), 33 deletions(-)
create mode 100644 public/app/features/browse-dashboards/components/utils.test.ts
diff --git a/public/app/features/browse-dashboards/components/NewFolderForm.tsx b/public/app/features/browse-dashboards/components/NewFolderForm.tsx
index 25ee5b5282f..a5c8fdb1043 100644
--- a/public/app/features/browse-dashboards/components/NewFolderForm.tsx
+++ b/public/app/features/browse-dashboards/components/NewFolderForm.tsx
@@ -28,18 +28,6 @@ export function NewFolderForm({ onCancel, onConfirm }: Props) {
'browse-dashboards.action.new-folder-name-required-phrase',
'Folder name is required.'
);
- const validateFolderName = async (folderName: string) => {
- try {
- await validationSrv.validateNewFolderName(folderName);
- return true;
- } catch (e) {
- if (e instanceof Error) {
- return e.message;
- } else {
- throw e;
- }
- }
- };
const fieldNameLabel = t('browse-dashboards.new-folder-form.name-label', 'Folder name');
@@ -75,3 +63,16 @@ export function NewFolderForm({ onCancel, onConfirm }: Props) {
);
}
+
+export async function validateFolderName(folderName: string) {
+ try {
+ await validationSrv.validateNewFolderName(folderName);
+ return true;
+ } catch (e) {
+ if (e instanceof Error) {
+ return e.message;
+ } else {
+ throw e;
+ }
+ }
+}
diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
index f79cb371636..f7f141fac01 100644
--- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
+++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx
@@ -1,22 +1,26 @@
+import { css } from '@emotion/css';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
-import { AppEvents } from '@grafana/data';
+import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
-import { Alert, Button, Field, Input, Stack } from '@grafana/ui';
+import { Alert, Text, Button, Field, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
import { Folder } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeySourcePath, Resource } from 'app/features/apiserver/types';
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared';
-import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam';
import { FolderDTO } from 'app/types/folders';
import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
+
+import { validateFolderName } from './NewFolderForm';
+import { formatFolderName, hasFolderNameCharactersToReplace } from './utils';
+
interface FormProps extends Props {
initialValues: BaseProvisionedFormData;
repository?: RepositoryView;
@@ -40,7 +44,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
});
const { handleSubmit, watch, register, formState } = methods;
- const [workflow, ref] = watch(['workflow', 'ref']);
+ const [workflow, ref, title] = watch(['workflow', 'ref', 'title']);
// TODO: replace with useProvisionedRequestHandler hook
useEffect(() => {
@@ -82,18 +86,6 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
}
}, [request.isSuccess, request.isError, request.error, ref, request.data, workflow, navigate, repository, onDismiss]);
- const validateFolderName = async (folderName: string) => {
- try {
- await validationSrv.validateNewFolderName(folderName);
- return true;
- } catch (e) {
- if (e instanceof Error) {
- return e.message;
- }
- return t('browse-dashboards.new-provisioned-folder-form.error-invalid-folder-name', 'Invalid folder name');
- }
- };
-
const doSave = async ({ ref, title, workflow, comment }: BaseProvisionedFormData) => {
const repoName = repository?.name;
if (!title || !repoName) {
@@ -102,10 +94,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
const basePath = folder?.metadata?.annotations?.[AnnoKeySourcePath] ?? '';
// Convert folder title to filename format (lowercase, replace spaces with hyphens)
- const titleInFilenameFormat = title
- .toLowerCase()
- .replace(/\s+/g, '-')
- .replace(/[^a-z0-9-]/g, '');
+ const titleInFilenameFormat = formatFolderName(title); // TODO: this is currently not working, issue created https://github.com/grafana/git-ui-sync-project/issues/314
const prefix = basePath ? `${basePath}/` : '';
const path = `${prefix}${titleInFilenameFormat}/`;
@@ -163,6 +152,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, isGit
id="folder-name-input"
/>
+
);
}
+
+function FolderNamePreviewMessage({ folderName }: { folderName: string }) {
+ const styles = useStyles2(getStyles);
+ const isValidFolderName =
+ folderName.length && hasFolderNameCharactersToReplace(folderName) && validateFolderName(folderName);
+
+ if (!isValidFolderName) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t(
+ 'browse-dashboards.new-provisioned-folder-form.text-your-folder-will-be-created-as',
+ 'Your folder will be created as {{folderName}}',
+ {
+ folderName: formatFolderName(folderName),
+ }
+ )}
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ folderNameMessage: css({
+ display: 'flex',
+ alignItems: 'center',
+ fontSize: theme.typography.bodySmall.fontSize,
+ color: theme.colors.success.text,
+ }),
+ };
+};
diff --git a/public/app/features/browse-dashboards/components/utils.test.ts b/public/app/features/browse-dashboards/components/utils.test.ts
new file mode 100644
index 00000000000..2bf48e3d329
--- /dev/null
+++ b/public/app/features/browse-dashboards/components/utils.test.ts
@@ -0,0 +1,124 @@
+import { formatFolderName, hasFolderNameCharactersToReplace } from './utils';
+
+describe('formatFolderName', () => {
+ it('should handle empty string', () => {
+ expect(formatFolderName('')).toBe('');
+ });
+
+ it('should convert uppercase to lowercase', () => {
+ expect(formatFolderName('MyFolder')).toBe('myfolder');
+ expect(formatFolderName('UPPERCASE')).toBe('uppercase');
+ expect(formatFolderName('MiXeD cAsE')).toBe('mixed-case');
+ });
+
+ it('should replace whitespace with hyphens', () => {
+ expect(formatFolderName('folder name')).toBe('folder-name');
+ expect(formatFolderName('folder name')).toBe('folder-name'); // multiple spaces
+ expect(formatFolderName('folder\tname')).toBe('folder-name'); // tab
+ expect(formatFolderName('folder\nname')).toBe('folder-name'); // newline
+ expect(formatFolderName(' folder name ')).toBe('folder-name'); // leading/trailing spaces
+ });
+
+ it('should remove special characters', () => {
+ expect(formatFolderName('folder@name')).toBe('foldername');
+ expect(formatFolderName('folder!@#$%^&*()name')).toBe('foldername');
+ expect(formatFolderName('folder_name')).toBe('foldername');
+ expect(formatFolderName('folder.name')).toBe('foldername');
+ expect(formatFolderName('folder/name')).toBe('foldername');
+ });
+
+ it('should preserve numbers and hyphens', () => {
+ expect(formatFolderName('folder-123')).toBe('folder-123');
+ expect(formatFolderName('folder123')).toBe('folder123');
+ expect(formatFolderName('123-folder')).toBe('123-folder');
+ expect(formatFolderName('folder-name-123')).toBe('folder-name-123');
+ });
+
+ it('should handle complex mixed cases', () => {
+ expect(formatFolderName('My Folder @2023!')).toBe('my-folder-2023');
+ expect(formatFolderName(' FOLDER_NAME with-123 ')).toBe('foldername-with-123');
+ expect(formatFolderName('Test@Folder#Name$123')).toBe('testfoldername123');
+ expect(formatFolderName('Multiple Spaces Between')).toBe('multiple-spaces-between');
+ });
+
+ it('should handle strings with only special characters', () => {
+ expect(formatFolderName('!@#$%^&*()')).toBe('');
+ expect(formatFolderName('___')).toBe('');
+ expect(formatFolderName('...')).toBe('');
+ });
+
+ it('should handle strings with only whitespace', () => {
+ expect(formatFolderName(' ')).toBe('');
+ expect(formatFolderName('\t\n\r')).toBe('');
+ });
+
+ it('should handle already formatted names', () => {
+ expect(formatFolderName('already-formatted')).toBe('already-formatted');
+ expect(formatFolderName('folder123')).toBe('folder123');
+ expect(formatFolderName('test-folder-name-123')).toBe('test-folder-name-123');
+ });
+});
+
+describe('hasFolderNameCharactersToReplace', () => {
+ it('should return false for non-string inputs', () => {
+ // @ts-expect-error
+ expect(hasFolderNameCharactersToReplace(null)).toBe(false);
+ // @ts-expect-error
+ expect(hasFolderNameCharactersToReplace(undefined)).toBe(false);
+ // @ts-expect-error
+ expect(hasFolderNameCharactersToReplace(123)).toBe(false);
+ // @ts-expect-error
+ expect(hasFolderNameCharactersToReplace({})).toBe(false);
+ // @ts-expect-error
+ expect(hasFolderNameCharactersToReplace([])).toBe(false);
+ });
+
+ it('should return false for empty string', () => {
+ expect(hasFolderNameCharactersToReplace('')).toBe(false);
+ });
+
+ it('should return false for valid folder names', () => {
+ expect(hasFolderNameCharactersToReplace('validname')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('folder123')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('test-folder-name')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('folder-123')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('123-folder')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('a')).toBe(false);
+ expect(hasFolderNameCharactersToReplace('1')).toBe(false);
+ });
+
+ it('should return true for names with whitespace', () => {
+ expect(hasFolderNameCharactersToReplace('folder name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder\tname')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder\nname')).toBe(true);
+ expect(hasFolderNameCharactersToReplace(' folder')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder ')).toBe(true);
+ expect(hasFolderNameCharactersToReplace(' ')).toBe(true);
+ });
+
+ it('should return true for names with uppercase letters', () => {
+ expect(hasFolderNameCharactersToReplace('FolderName')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('UPPERCASE')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('MiXeD')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder-Name')).toBe(true);
+ });
+
+ it('should return true for names with special characters', () => {
+ expect(hasFolderNameCharactersToReplace('folder@name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder!name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder_name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder.name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder/name')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('folder#name')).toBe(true);
+ });
+
+ it('should return true for mixed cases with multiple issues', () => {
+ expect(hasFolderNameCharactersToReplace('Test@Folder#Name$123')).toBe(true);
+ expect(hasFolderNameCharactersToReplace('Multiple Spaces Between')).toBe(true);
+ });
+
+ it('should return true for strings with only special characters', () => {
+ expect(hasFolderNameCharactersToReplace('!@#$%^&*()')).toBe(true);
+ });
+});
diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts
index af40b5e2f0f..8f29702bda7 100644
--- a/public/app/features/browse-dashboards/components/utils.ts
+++ b/public/app/features/browse-dashboards/components/utils.ts
@@ -22,3 +22,38 @@ export function getFolderURL(uid: string) {
}
return url;
}
+
+export function hasFolderNameCharactersToReplace(folderName: string): boolean {
+ if (typeof folderName !== 'string') {
+ return false;
+ }
+
+ // whitespace that needs to be replaced with hyphens
+ const hasWhitespace = /\s+/.test(folderName);
+
+ // characters that are not lowercase letters, numbers, or hyphens
+ const hasInvalidCharacters = /[^a-z0-9-]/.test(folderName);
+
+ return hasWhitespace || hasInvalidCharacters;
+}
+
+export function formatFolderName(folderName?: string): string {
+ if (typeof folderName !== 'string') {
+ console.error('Invalid folder name type:', typeof folderName);
+ return '';
+ }
+
+ const result = folderName
+ .trim() // Remove leading/trailing whitespace first
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
+
+ // If the result is empty, return empty string
+ if (result === '') {
+ return '';
+ }
+
+ return result;
+}
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 07558dd8558..2f22f8556d4 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -3550,11 +3550,11 @@
"button-create": "Create",
"button-creating": "Creating...",
"cancel": "Cancel",
- "error-invalid-folder-name": "Invalid folder name",
"error-required": "Folder name is required",
"folder-name-input-placeholder-enter-folder-name": "Enter folder name",
"label-folder-name": "Folder name",
"text-pull-request-created": "A pull request has been created with changes to this folder:",
+ "text-your-folder-will-be-created-as": "Your folder will be created as {{folderName}}",
"title-pull-request-created": "Pull request created",
"title-this-repository-is-read-only": "This repository is read only"
},
From 5d0f519c0d9de3cbf84e2c4eb767969cb8d79212 Mon Sep 17 00:00:00 2001
From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
Date: Thu, 10 Jul 2025 09:02:53 +0200
Subject: [PATCH 03/33] Docs: Update min supported Loki version to v2.9+
(#107853)
---
docs/sources/datasources/loki/_index.md | 2 +-
pkg/services/featuremgmt/registry.go | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/sources/datasources/loki/_index.md b/docs/sources/datasources/loki/_index.md
index 523be2c9fbc..4176a2771e7 100644
--- a/docs/sources/datasources/loki/_index.md
+++ b/docs/sources/datasources/loki/_index.md
@@ -62,7 +62,7 @@ The following guides will help you get started with Loki:
This data source supports these versions of Loki:
-- v2.8+
+- v2.9+
## Adding a data source
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index fe4c102bfc4..c099d584788 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -1320,6 +1320,8 @@ var (
FrontendOnly: true,
},
{
+ // Remove this flag once Loki v4 is released and the min supported version is v3.0+,
+ // since users on v2.9 need it to disable the feature, as it doesn't work for them.
Name: "lokiLabelNamesQueryApi",
Description: "Defaults to using the Loki `/labels` API instead of `/series`",
Stage: FeatureStageGeneralAvailability,
From 85a6a7b9c15e0220ec0a25e4c511009adb59fc3e Mon Sep 17 00:00:00 2001
From: Gabriel MABILLE
Date: Thu, 10 Jul 2025 09:24:30 +0200
Subject: [PATCH 04/33] `iam`: add description field to roles (#107888)
* iam: add description field to roles
* Openapi gen
* Revert launch change
---
apps/iam/kinds/v0alpha1/rolespec.cue | 1 +
.../apis/iam/v0alpha1/corerole_spec_gen.go | 7 ++---
.../apis/iam/v0alpha1/globalrole_spec_gen.go | 7 ++---
.../pkg/apis/iam/v0alpha1/role_spec_gen.go | 7 ++---
.../pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 27 ++++++++++++++++---
.../iam.grafana.app-v0alpha1.json | 15 +++++++++++
6 files changed, 52 insertions(+), 12 deletions(-)
diff --git a/apps/iam/kinds/v0alpha1/rolespec.cue b/apps/iam/kinds/v0alpha1/rolespec.cue
index 845ae207c28..b7934f0174e 100644
--- a/apps/iam/kinds/v0alpha1/rolespec.cue
+++ b/apps/iam/kinds/v0alpha1/rolespec.cue
@@ -10,6 +10,7 @@ RoleSpec: {
// Display name of the role
title: string
+ description: string
version: int
group: string
diff --git a/apps/iam/pkg/apis/iam/v0alpha1/corerole_spec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/corerole_spec_gen.go
index eb2c1d52e40..ac0b41e1972 100644
--- a/apps/iam/pkg/apis/iam/v0alpha1/corerole_spec_gen.go
+++ b/apps/iam/pkg/apis/iam/v0alpha1/corerole_spec_gen.go
@@ -18,9 +18,10 @@ func NewCoreRolespecPermission() *CoreRolespecPermission {
// +k8s:openapi-gen=true
type CoreRoleSpec struct {
// Display name of the role
- Title string `json:"title"`
- Version int64 `json:"version"`
- Group string `json:"group"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Version int64 `json:"version"`
+ Group string `json:"group"`
// TODO:
// delegatable?: bool
// created?
diff --git a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_spec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_spec_gen.go
index 785d2d5724b..1ce8d65646f 100644
--- a/apps/iam/pkg/apis/iam/v0alpha1/globalrole_spec_gen.go
+++ b/apps/iam/pkg/apis/iam/v0alpha1/globalrole_spec_gen.go
@@ -18,9 +18,10 @@ func NewGlobalRolespecPermission() *GlobalRolespecPermission {
// +k8s:openapi-gen=true
type GlobalRoleSpec struct {
// Display name of the role
- Title string `json:"title"`
- Version int64 `json:"version"`
- Group string `json:"group"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Version int64 `json:"version"`
+ Group string `json:"group"`
// TODO:
// delegatable?: bool
// created?
diff --git a/apps/iam/pkg/apis/iam/v0alpha1/role_spec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/role_spec_gen.go
index f1a165dba4e..5a8185cd25c 100644
--- a/apps/iam/pkg/apis/iam/v0alpha1/role_spec_gen.go
+++ b/apps/iam/pkg/apis/iam/v0alpha1/role_spec_gen.go
@@ -18,9 +18,10 @@ func NewRolespecPermission() *RolespecPermission {
// +k8s:openapi-gen=true
type RoleSpec struct {
// Display name of the role
- Title string `json:"title"`
- Version int64 `json:"version"`
- Group string `json:"group"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Version int64 `json:"version"`
+ Group string `json:"group"`
// TODO:
// delegatable?: bool
// created?
diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
index 406834348e3..7a0703d53e3 100644
--- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
+++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
@@ -186,6 +186,13 @@ func schema_pkg_apis_iam_v0alpha1_CoreRoleSpec(ref common.ReferenceCallback) com
Format: "",
},
},
+ "description": {
+ SchemaProps: spec.SchemaProps{
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
"version": {
SchemaProps: spec.SchemaProps{
Default: 0,
@@ -215,7 +222,7 @@ func schema_pkg_apis_iam_v0alpha1_CoreRoleSpec(ref common.ReferenceCallback) com
},
},
},
- Required: []string{"title", "version", "group", "permissions"},
+ Required: []string{"title", "description", "version", "group", "permissions"},
},
},
Dependencies: []string{
@@ -740,6 +747,13 @@ func schema_pkg_apis_iam_v0alpha1_GlobalRoleSpec(ref common.ReferenceCallback) c
Format: "",
},
},
+ "description": {
+ SchemaProps: spec.SchemaProps{
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
"version": {
SchemaProps: spec.SchemaProps{
Default: 0,
@@ -769,7 +783,7 @@ func schema_pkg_apis_iam_v0alpha1_GlobalRoleSpec(ref common.ReferenceCallback) c
},
},
},
- Required: []string{"title", "version", "group", "permissions"},
+ Required: []string{"title", "description", "version", "group", "permissions"},
},
},
Dependencies: []string{
@@ -1600,6 +1614,13 @@ func schema_pkg_apis_iam_v0alpha1_RoleSpec(ref common.ReferenceCallback) common.
Format: "",
},
},
+ "description": {
+ SchemaProps: spec.SchemaProps{
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
"version": {
SchemaProps: spec.SchemaProps{
Default: 0,
@@ -1629,7 +1650,7 @@ func schema_pkg_apis_iam_v0alpha1_RoleSpec(ref common.ReferenceCallback) common.
},
},
},
- Required: []string{"title", "version", "group", "permissions"},
+ Required: []string{"title", "description", "version", "group", "permissions"},
},
},
Dependencies: []string{
diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json
index b1b2dc77570..6c3fae072bb 100644
--- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json
+++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json
@@ -2923,11 +2923,16 @@
"type": "object",
"required": [
"title",
+ "description",
"version",
"group",
"permissions"
],
"properties": {
+ "description": {
+ "type": "string",
+ "default": ""
+ },
"group": {
"type": "string",
"default": ""
@@ -3236,11 +3241,16 @@
"type": "object",
"required": [
"title",
+ "description",
"version",
"group",
"permissions"
],
"properties": {
+ "description": {
+ "type": "string",
+ "default": ""
+ },
"group": {
"type": "string",
"default": ""
@@ -3723,11 +3733,16 @@
"type": "object",
"required": [
"title",
+ "description",
"version",
"group",
"permissions"
],
"properties": {
+ "description": {
+ "type": "string",
+ "default": ""
+ },
"group": {
"type": "string",
"default": ""
From e4650d3d8f705dc00e76c5e89da51668ea7af6ab Mon Sep 17 00:00:00 2001
From: Andres Martinez Gotor
Date: Thu, 10 Jul 2025 09:55:10 +0200
Subject: [PATCH 05/33] Advisor: Update app-sdk and regenerate code (#107786)
---
apps/advisor/Makefile | 23 ++++-
apps/advisor/go.mod | 12 +--
apps/advisor/go.sum | 7 ++
.../advisor/v0alpha1/check_metadata_gen.go | 5 +-
.../apis/advisor/v0alpha1/check_object_gen.go | 63 +++++++++++--
.../apis/advisor/v0alpha1/check_status_gen.go | 52 ++++++-----
.../v0alpha1/checktype_metadata_gen.go | 5 +-
.../advisor/v0alpha1/checktype_object_gen.go | 63 +++++++++++--
.../advisor/v0alpha1/checktype_spec_gen.go | 4 +-
.../pkg/apis/advisor/v0alpha1/constants.go | 12 +--
.../apis/advisor/v0alpha1/zz_openapi_gen.go | 90 ++++++++++---------
apps/advisor/pkg/apis/advisor_manifest.go | 26 ++++--
apps/advisor/pkg/app/checks/utils.go | 22 +++++
apps/advisor/pkg/app/utils.go | 39 +++-----
apps/advisor/pkg/app/utils_test.go | 18 ++--
.../pluginsintegration/advisor/advisor.go | 4 +-
.../advisor/advisor_test.go | 12 +--
.../advisor.grafana.app-v0alpha1.json | 58 ++++++------
18 files changed, 341 insertions(+), 174 deletions(-)
diff --git a/apps/advisor/Makefile b/apps/advisor/Makefile
index 7a5f1c99365..b8b335bfa68 100644
--- a/apps/advisor/Makefile
+++ b/apps/advisor/Makefile
@@ -1,3 +1,22 @@
+APP_SDK_VERSION := v0.39.2
+APP_SDK_DIR := $(shell go env GOPATH)/bin/app-sdk-$(APP_SDK_VERSION)
+APP_SDK_BIN := $(APP_SDK_DIR)/grafana-app-sdk
+
+.PHONY: install-app-sdk
+install-app-sdk: $(APP_SDK_BIN) ## Install the Grafana App SDK
+
+$(APP_SDK_BIN):
+ @echo "Installing Grafana App SDK version $(APP_SDK_VERSION)"
+ @mkdir -p $(APP_SDK_DIR)
+ # The only way to install specific versions of binaries using `go install`
+ # is by setting GOBIN to the directory you want to install the binary to.
+ GOBIN=$(APP_SDK_DIR) go install github.com/grafana/grafana-app-sdk/cmd/grafana-app-sdk@$(APP_SDK_VERSION)
+ @touch $@
+
+.PHONY: update-app-sdk
+update-app-sdk: ## Update the Grafana App SDK dependency in go.mod
+ go get github.com/grafana/grafana-app-sdk@$(APP_SDK_VERSION)
+
.PHONY: generate
-generate:
- @grafana-app-sdk generate -g ./pkg/apis --grouping=group --postprocess --defencoding=none
+generate: ## Run Grafana App SDK code generation
+ @$(APP_SDK_BIN) generate -g ./pkg/apis --grouping=group --postprocess --defencoding=none
diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod
index 45a7ed0b51e..65832aabb4b 100644
--- a/apps/advisor/go.mod
+++ b/apps/advisor/go.mod
@@ -3,8 +3,8 @@ module github.com/grafana/grafana/apps/advisor
go 1.24.4
require (
- github.com/grafana/grafana-app-sdk v0.39.0
- k8s.io/apimachinery v0.33.1
+ github.com/grafana/grafana-app-sdk v0.39.2
+ k8s.io/apimachinery v0.33.2
k8s.io/klog/v2 v2.130.1
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff
)
@@ -33,7 +33,7 @@ require (
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // indirect
- github.com/grafana/grafana-app-sdk/logging v0.38.2 // indirect
+ github.com/grafana/grafana-app-sdk/logging v0.39.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -79,9 +79,9 @@ require (
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/api v0.33.1 // indirect
- k8s.io/apiextensions-apiserver v0.33.1 // indirect
- k8s.io/client-go v0.33.1 // indirect
+ k8s.io/api v0.33.2 // indirect
+ k8s.io/apiextensions-apiserver v0.33.2 // indirect
+ k8s.io/client-go v0.33.2 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum
index e7629867703..90b34992392 100644
--- a/apps/advisor/go.sum
+++ b/apps/advisor/go.sum
@@ -63,11 +63,14 @@ github.com/grafana/grafana-app-sdk v0.30.0/go.mod h1:jhfqNIovb+Mes2vdMf9iMCWQkp1
github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs=
github.com/grafana/grafana-app-sdk v0.35.1/go.mod h1:Zx5MkVppYK+ElSDUAR6+fjzOVo6I/cIgk+ty+LmNOxI=
github.com/grafana/grafana-app-sdk v0.39.0/go.mod h1:xRyBQOttgWTc3tGe9pI0upnpEPVhzALf7Mh/61O4zyY=
+github.com/grafana/grafana-app-sdk v0.39.2 h1:ymfr+1318t+JC9U2OYrzVpGmNG/aJONUmFFu/G98Xh8=
+github.com/grafana/grafana-app-sdk v0.39.2/go.mod h1:t0m6q561lpoHQCixS9LUHFUhUzDClzNtm7BH60gHVSY=
github.com/grafana/grafana-app-sdk/logging v0.29.0 h1:mgbXaAf33aFwqwGVeaX30l8rkeAJH0iACgX5Rn6YkN4=
github.com/grafana/grafana-app-sdk/logging v0.29.0/go.mod h1:xy6ZyVXl50Z3DBDLybvBPphbykPhuVNed/VNmen9DQM=
github.com/grafana/grafana-app-sdk/logging v0.30.0/go.mod h1:xy6ZyVXl50Z3DBDLybvBPphbykPhuVNed/VNmen9DQM=
github.com/grafana/grafana-app-sdk/logging v0.35.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
github.com/grafana/grafana-app-sdk/logging v0.38.2/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
+github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
@@ -302,21 +305,25 @@ k8s.io/api v0.32.0/go.mod h1:4LEwHZEf6Q/cG96F3dqR965sYOfmPM7rq81BLgsE0p0=
k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k=
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw=
+k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs=
k8s.io/apiextensions-apiserver v0.32.0 h1:S0Xlqt51qzzqjKPxfgX1xh4HBZE+p8KKBq+k2SWNOE0=
k8s.io/apiextensions-apiserver v0.32.0/go.mod h1:86hblMvN5yxMvZrZFX2OhIHAuFIMJIZ19bTvzkP+Fmw=
k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto=
k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss=
k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA=
+k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8=
k8s.io/apimachinery v0.32.0 h1:cFSE7N3rmEEtv4ei5X6DaJPHHX0C+upp+v5lVPiEwpg=
k8s.io/apimachinery v0.32.0/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
+k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg=
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA=
+k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/check_metadata_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/check_metadata_gen.go
index 4b385514d97..0150b059719 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/check_metadata_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/check_metadata_gen.go
@@ -24,5 +24,8 @@ type CheckMetadata struct {
// NewCheckMetadata creates a new CheckMetadata object.
func NewCheckMetadata() *CheckMetadata {
- return &CheckMetadata{}
+ return &CheckMetadata{
+ Finalizers: []string{},
+ Labels: map[string]string{},
+ }
}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/check_object_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/check_object_gen.go
index aca8c2876d6..5708bba88d6 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/check_object_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/check_object_gen.go
@@ -18,8 +18,11 @@ import (
type Check struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
- Spec CheckSpec `json:"spec" yaml:"spec"`
- CheckStatus CheckStatus `json:"status" yaml:"status"`
+
+ // Spec is the spec of the Check
+ Spec CheckSpec `json:"spec" yaml:"spec"`
+
+ Status CheckStatus `json:"status" yaml:"status"`
}
func (o *Check) GetSpec() any {
@@ -37,14 +40,14 @@ func (o *Check) SetSpec(spec any) error {
func (o *Check) GetSubresources() map[string]any {
return map[string]any{
- "status": o.CheckStatus,
+ "status": o.Status,
}
}
func (o *Check) GetSubresource(name string) (any, bool) {
switch name {
case "status":
- return o.CheckStatus, true
+ return o.Status, true
default:
return nil, false
}
@@ -57,7 +60,7 @@ func (o *Check) SetSubresource(name string, value any) error {
if !ok {
return fmt.Errorf("cannot set status type %#v, not of type CheckStatus", value)
}
- o.CheckStatus = cast
+ o.Status = cast
return nil
default:
return fmt.Errorf("subresource '%s' does not exist", name)
@@ -219,6 +222,20 @@ func (o *Check) DeepCopyObject() runtime.Object {
return o.Copy()
}
+func (o *Check) DeepCopy() *Check {
+ cpy := &Check{}
+ o.DeepCopyInto(cpy)
+ return cpy
+}
+
+func (o *Check) DeepCopyInto(dst *Check) {
+ dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
+ dst.TypeMeta.Kind = o.TypeMeta.Kind
+ o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
+ o.Spec.DeepCopyInto(&dst.Spec)
+ o.Status.DeepCopyInto(&dst.Status)
+}
+
// Interface compliance compile-time check
var _ resource.Object = &Check{}
@@ -262,5 +279,41 @@ func (o *CheckList) SetItems(items []resource.Object) {
}
}
+func (o *CheckList) DeepCopy() *CheckList {
+ cpy := &CheckList{}
+ o.DeepCopyInto(cpy)
+ return cpy
+}
+
+func (o *CheckList) DeepCopyInto(dst *CheckList) {
+ resource.CopyObjectInto(dst, o)
+}
+
// Interface compliance compile-time check
var _ resource.ListObject = &CheckList{}
+
+// Copy methods for all subresource types
+
+// DeepCopy creates a full deep copy of Spec
+func (s *CheckSpec) DeepCopy() *CheckSpec {
+ cpy := &CheckSpec{}
+ s.DeepCopyInto(cpy)
+ return cpy
+}
+
+// DeepCopyInto deep copies Spec into another Spec object
+func (s *CheckSpec) DeepCopyInto(dst *CheckSpec) {
+ resource.CopyObjectInto(dst, s)
+}
+
+// DeepCopy creates a full deep copy of CheckStatus
+func (s *CheckStatus) DeepCopy() *CheckStatus {
+ cpy := &CheckStatus{}
+ s.DeepCopyInto(cpy)
+ return cpy
+}
+
+// DeepCopyInto deep copies CheckStatus into another CheckStatus object
+func (s *CheckStatus) DeepCopyInto(dst *CheckStatus) {
+ resource.CopyObjectInto(dst, s)
+}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go
index b77811fefc2..8716958ce02 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/check_status_gen.go
@@ -3,16 +3,18 @@
package v0alpha1
// +k8s:openapi-gen=true
-type CheckErrorLink struct {
- // URL to a page with more information about the error
- Url string `json:"url"`
- // Human readable error message
- Message string `json:"message"`
+type CheckReport struct {
+ // Number of elements analyzed
+ Count int64 `json:"count"`
+ // List of failures
+ Failures []CheckReportFailure `json:"failures"`
}
-// NewCheckErrorLink creates a new CheckErrorLink object.
-func NewCheckErrorLink() *CheckErrorLink {
- return &CheckErrorLink{}
+// NewCheckReport creates a new CheckReport object.
+func NewCheckReport() *CheckReport {
+ return &CheckReport{
+ Failures: []CheckReportFailure{},
+ }
}
// +k8s:openapi-gen=true
@@ -33,7 +35,22 @@ type CheckReportFailure struct {
// NewCheckReportFailure creates a new CheckReportFailure object.
func NewCheckReportFailure() *CheckReportFailure {
- return &CheckReportFailure{}
+ return &CheckReportFailure{
+ Links: []CheckErrorLink{},
+ }
+}
+
+// +k8s:openapi-gen=true
+type CheckErrorLink struct {
+ // URL to a page with more information about the error
+ Url string `json:"url"`
+ // Human readable error message
+ Message string `json:"message"`
+}
+
+// NewCheckErrorLink creates a new CheckErrorLink object.
+func NewCheckErrorLink() *CheckErrorLink {
+ return &CheckErrorLink{}
}
// +k8s:openapi-gen=true
@@ -56,7 +73,7 @@ func NewCheckstatusOperatorState() *CheckstatusOperatorState {
// +k8s:openapi-gen=true
type CheckStatus struct {
- Report CheckV0alpha1StatusReport `json:"report"`
+ Report CheckReport `json:"report"`
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
OperatorStates map[string]CheckstatusOperatorState `json:"operatorStates,omitempty"`
@@ -67,7 +84,7 @@ type CheckStatus struct {
// NewCheckStatus creates a new CheckStatus object.
func NewCheckStatus() *CheckStatus {
return &CheckStatus{
- Report: *NewCheckV0alpha1StatusReport(),
+ Report: *NewCheckReport(),
}
}
@@ -87,16 +104,3 @@ const (
CheckStatusOperatorStateStateInProgress CheckStatusOperatorStateState = "in_progress"
CheckStatusOperatorStateStateFailed CheckStatusOperatorStateState = "failed"
)
-
-// +k8s:openapi-gen=true
-type CheckV0alpha1StatusReport struct {
- // Number of elements analyzed
- Count int64 `json:"count"`
- // List of failures
- Failures []CheckReportFailure `json:"failures"`
-}
-
-// NewCheckV0alpha1StatusReport creates a new CheckV0alpha1StatusReport object.
-func NewCheckV0alpha1StatusReport() *CheckV0alpha1StatusReport {
- return &CheckV0alpha1StatusReport{}
-}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_metadata_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_metadata_gen.go
index 9998cffb37d..05e019ec5a7 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_metadata_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_metadata_gen.go
@@ -24,5 +24,8 @@ type CheckTypeMetadata struct {
// NewCheckTypeMetadata creates a new CheckTypeMetadata object.
func NewCheckTypeMetadata() *CheckTypeMetadata {
- return &CheckTypeMetadata{}
+ return &CheckTypeMetadata{
+ Finalizers: []string{},
+ Labels: map[string]string{},
+ }
}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_object_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_object_gen.go
index 80bd02b1cc8..22d4962c730 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_object_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_object_gen.go
@@ -18,8 +18,11 @@ import (
type CheckType struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata" yaml:"metadata"`
- Spec CheckTypeSpec `json:"spec" yaml:"spec"`
- CheckTypeStatus CheckTypeStatus `json:"status" yaml:"status"`
+
+ // Spec is the spec of the CheckType
+ Spec CheckTypeSpec `json:"spec" yaml:"spec"`
+
+ Status CheckTypeStatus `json:"status" yaml:"status"`
}
func (o *CheckType) GetSpec() any {
@@ -37,14 +40,14 @@ func (o *CheckType) SetSpec(spec any) error {
func (o *CheckType) GetSubresources() map[string]any {
return map[string]any{
- "status": o.CheckTypeStatus,
+ "status": o.Status,
}
}
func (o *CheckType) GetSubresource(name string) (any, bool) {
switch name {
case "status":
- return o.CheckTypeStatus, true
+ return o.Status, true
default:
return nil, false
}
@@ -57,7 +60,7 @@ func (o *CheckType) SetSubresource(name string, value any) error {
if !ok {
return fmt.Errorf("cannot set status type %#v, not of type CheckTypeStatus", value)
}
- o.CheckTypeStatus = cast
+ o.Status = cast
return nil
default:
return fmt.Errorf("subresource '%s' does not exist", name)
@@ -219,6 +222,20 @@ func (o *CheckType) DeepCopyObject() runtime.Object {
return o.Copy()
}
+func (o *CheckType) DeepCopy() *CheckType {
+ cpy := &CheckType{}
+ o.DeepCopyInto(cpy)
+ return cpy
+}
+
+func (o *CheckType) DeepCopyInto(dst *CheckType) {
+ dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
+ dst.TypeMeta.Kind = o.TypeMeta.Kind
+ o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
+ o.Spec.DeepCopyInto(&dst.Spec)
+ o.Status.DeepCopyInto(&dst.Status)
+}
+
// Interface compliance compile-time check
var _ resource.Object = &CheckType{}
@@ -262,5 +279,41 @@ func (o *CheckTypeList) SetItems(items []resource.Object) {
}
}
+func (o *CheckTypeList) DeepCopy() *CheckTypeList {
+ cpy := &CheckTypeList{}
+ o.DeepCopyInto(cpy)
+ return cpy
+}
+
+func (o *CheckTypeList) DeepCopyInto(dst *CheckTypeList) {
+ resource.CopyObjectInto(dst, o)
+}
+
// Interface compliance compile-time check
var _ resource.ListObject = &CheckTypeList{}
+
+// Copy methods for all subresource types
+
+// DeepCopy creates a full deep copy of Spec
+func (s *CheckTypeSpec) DeepCopy() *CheckTypeSpec {
+ cpy := &CheckTypeSpec{}
+ s.DeepCopyInto(cpy)
+ return cpy
+}
+
+// DeepCopyInto deep copies Spec into another Spec object
+func (s *CheckTypeSpec) DeepCopyInto(dst *CheckTypeSpec) {
+ resource.CopyObjectInto(dst, s)
+}
+
+// DeepCopy creates a full deep copy of CheckTypeStatus
+func (s *CheckTypeStatus) DeepCopy() *CheckTypeStatus {
+ cpy := &CheckTypeStatus{}
+ s.DeepCopyInto(cpy)
+ return cpy
+}
+
+// DeepCopyInto deep copies CheckTypeStatus into another CheckTypeStatus object
+func (s *CheckTypeStatus) DeepCopyInto(dst *CheckTypeStatus) {
+ resource.CopyObjectInto(dst, s)
+}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_spec_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_spec_gen.go
index 528643b69dd..d4d3f2d4f86 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_spec_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/checktype_spec_gen.go
@@ -23,5 +23,7 @@ type CheckTypeSpec struct {
// NewCheckTypeSpec creates a new CheckTypeSpec object.
func NewCheckTypeSpec() *CheckTypeSpec {
- return &CheckTypeSpec{}
+ return &CheckTypeSpec{
+ Steps: []CheckTypeStep{},
+ }
}
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/constants.go b/apps/advisor/pkg/apis/advisor/v0alpha1/constants.go
index 213249b4c58..80c8a0f0620 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/constants.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/constants.go
@@ -3,16 +3,16 @@ package v0alpha1
import "k8s.io/apimachinery/pkg/runtime/schema"
const (
- // Group is the API group used by all kinds in this package
- Group = "advisor.grafana.app"
- // Version is the API version used by all kinds in this package
- Version = "v0alpha1"
+ // APIGroup is the API group used by all kinds in this package
+ APIGroup = "advisor.grafana.app"
+ // APIVersion is the API version used by all kinds in this package
+ APIVersion = "v0alpha1"
)
var (
// GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package
GroupVersion = schema.GroupVersion{
- Group: Group,
- Version: Version,
+ Group: APIGroup,
+ Version: APIVersion,
}
)
diff --git a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go
index 84e85e7f5ba..21e61de7f45 100644
--- a/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go
+++ b/apps/advisor/pkg/apis/advisor/v0alpha1/zz_openapi_gen.go
@@ -15,6 +15,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.Check": schema_pkg_apis_advisor_v0alpha1_Check(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckErrorLink": schema_pkg_apis_advisor_v0alpha1_CheckErrorLink(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckList": schema_pkg_apis_advisor_v0alpha1_CheckList(ref),
+ "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReport": schema_pkg_apis_advisor_v0alpha1_CheckReport(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure": schema_pkg_apis_advisor_v0alpha1_CheckReportFailure(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec": schema_pkg_apis_advisor_v0alpha1_CheckSpec(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckStatus": schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref),
@@ -24,7 +25,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckTypeStatus": schema_pkg_apis_advisor_v0alpha1_CheckTypeStatus(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckTypeStep": schema_pkg_apis_advisor_v0alpha1_CheckTypeStep(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckTypestatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckTypestatusOperatorState(ref),
- "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport": schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref),
"github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState": schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref),
}
}
@@ -57,8 +57,9 @@ func schema_pkg_apis_advisor_v0alpha1_Check(ref common.ReferenceCallback) common
},
"spec": {
SchemaProps: spec.SchemaProps{
- Default: map[string]interface{}{},
- Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec"),
+ Description: "Spec is the spec of the Check",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckSpec"),
},
},
"status": {
@@ -153,6 +154,43 @@ func schema_pkg_apis_advisor_v0alpha1_CheckList(ref common.ReferenceCallback) co
}
}
+func schema_pkg_apis_advisor_v0alpha1_CheckReport(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "count": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Number of elements analyzed",
+ Default: 0,
+ Type: []string{"integer"},
+ Format: "int64",
+ },
+ },
+ "failures": {
+ SchemaProps: spec.SchemaProps{
+ Description: "List of failures",
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"),
+ },
+ },
+ },
+ },
+ },
+ },
+ Required: []string{"count", "failures"},
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"},
+ }
+}
+
func schema_pkg_apis_advisor_v0alpha1_CheckReportFailure(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -258,7 +296,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref common.ReferenceCallback)
"report": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
- Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport"),
+ Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReport"),
},
},
"operatorStates": {
@@ -296,7 +334,7 @@ func schema_pkg_apis_advisor_v0alpha1_CheckStatus(ref common.ReferenceCallback)
},
},
Dependencies: []string{
- "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckV0alpha1StatusReport", "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState"},
+ "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReport", "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckstatusOperatorState"},
}
}
@@ -328,8 +366,9 @@ func schema_pkg_apis_advisor_v0alpha1_CheckType(ref common.ReferenceCallback) co
},
"spec": {
SchemaProps: spec.SchemaProps{
- Default: map[string]interface{}{},
- Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckTypeSpec"),
+ Description: "Spec is the spec of the CheckType",
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckTypeSpec"),
},
},
"status": {
@@ -566,43 +605,6 @@ func schema_pkg_apis_advisor_v0alpha1_CheckTypestatusOperatorState(ref common.Re
}
}
-func schema_pkg_apis_advisor_v0alpha1_CheckV0alpha1StatusReport(ref common.ReferenceCallback) common.OpenAPIDefinition {
- return common.OpenAPIDefinition{
- Schema: spec.Schema{
- SchemaProps: spec.SchemaProps{
- Type: []string{"object"},
- Properties: map[string]spec.Schema{
- "count": {
- SchemaProps: spec.SchemaProps{
- Description: "Number of elements analyzed",
- Default: 0,
- Type: []string{"integer"},
- Format: "int64",
- },
- },
- "failures": {
- SchemaProps: spec.SchemaProps{
- Description: "List of failures",
- Type: []string{"array"},
- Items: &spec.SchemaOrArray{
- Schema: &spec.Schema{
- SchemaProps: spec.SchemaProps{
- Default: map[string]interface{}{},
- Ref: ref("github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"),
- },
- },
- },
- },
- },
- },
- Required: []string{"count", "failures"},
- },
- },
- Dependencies: []string{
- "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1.CheckReportFailure"},
- }
-}
-
func schema_pkg_apis_advisor_v0alpha1_CheckstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
diff --git a/apps/advisor/pkg/apis/advisor_manifest.go b/apps/advisor/pkg/apis/advisor_manifest.go
index 6f5d472dc54..eb596a274e2 100644
--- a/apps/advisor/pkg/apis/advisor_manifest.go
+++ b/apps/advisor/pkg/apis/advisor_manifest.go
@@ -7,15 +7,19 @@ package apis
import (
"encoding/json"
+ "fmt"
"github.com/grafana/grafana-app-sdk/app"
+ "github.com/grafana/grafana-app-sdk/resource"
+
+ v0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
)
var (
- rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"failures":{"description":"List of failures","items":{"properties":{"item":{"description":"Human readable identifier of the item that failed","type":"string"},"itemID":{"description":"ID of the item that failed","type":"string"},"links":{"description":"Links to actions that can be taken to resolve the failure","items":{"properties":{"message":{"description":"Human readable error message","type":"string"},"url":{"description":"URL to a page with more information about the error","type":"string"}},"required":["url","message"],"type":"object"},"type":"array"},"moreInfo":{"description":"More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.","type":"string"},"severity":{"description":"Severity of the failure","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the failure is associated with","type":"string"}},"required":["severity","stepID","item","itemID","links"],"type":"object"},"type":"array"}},"required":["count","failures"],"type":"object"}},"required":["report"],"type":"object","x-kubernetes-preserve-unknown-fields":true}}`)
+ rawSchemaCheckv0alpha1 = []byte(`{"spec":{"properties":{"data":{"additionalProperties":{"type":"string"},"description":"Generic data input that a check can receive","type":"object"}},"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"},"report":{"properties":{"count":{"description":"Number of elements analyzed","type":"integer"},"failures":{"description":"List of failures","items":{"properties":{"item":{"description":"Human readable identifier of the item that failed","type":"string"},"itemID":{"description":"ID of the item that failed","type":"string"},"links":{"description":"Links to actions that can be taken to resolve the failure","items":{"properties":{"message":{"description":"Human readable error message","type":"string"},"url":{"description":"URL to a page with more information about the error","type":"string"}},"required":["url","message"],"type":"object"},"type":"array"},"moreInfo":{"description":"More information about the failure, not meant to be displayed to the user. Used for LLM suggestions.","type":"string"},"severity":{"description":"Severity of the failure","enum":["high","low"],"type":"string"},"stepID":{"description":"Step ID that the failure is associated with","type":"string"}},"required":["severity","stepID","item","itemID","links"],"type":"object"},"type":"array"}},"required":["count","failures"],"type":"object"}},"required":["report"],"type":"object"}}`)
versionSchemaCheckv0alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaCheckv0alpha1, &versionSchemaCheckv0alpha1)
- rawSchemaCheckTypev0alpha1 = []byte(`{"spec":{"properties":{"name":{"type":"string"},"steps":{"items":{"properties":{"description":{"type":"string"},"resolution":{"type":"string"},"stepID":{"type":"string"},"title":{"type":"string"}},"required":["title","description","stepID","resolution"],"type":"object"},"type":"array"}},"required":["name","steps"],"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object","x-kubernetes-preserve-unknown-fields":true}}`)
+ rawSchemaCheckTypev0alpha1 = []byte(`{"spec":{"properties":{"name":{"type":"string"},"steps":{"items":{"properties":{"description":{"type":"string"},"resolution":{"type":"string"},"stepID":{"type":"string"},"title":{"type":"string"}},"required":["title","description","stepID","resolution"],"type":"object"},"type":"array"}},"required":["name","steps"],"type":"object"},"status":{"properties":{"additionalFields":{"description":"additionalFields is reserved for future use","type":"object","x-kubernetes-preserve-unknown-fields":true},"operatorStates":{"additionalProperties":{"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"description":"details contains any extra information that is operator-specific","type":"object","x-kubernetes-preserve-unknown-fields":true},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`)
versionSchemaCheckTypev0alpha1 app.VersionSchema
_ = json.Unmarshal(rawSchemaCheckTypev0alpha1, &versionSchemaCheckTypev0alpha1)
)
@@ -58,12 +62,6 @@ var appManifestData = app.ManifestData{
},
}
-func jsonToMap(j string) map[string]any {
- m := make(map[string]any)
- json.Unmarshal([]byte(j), &j)
- return m
-}
-
func LocalManifest() app.Manifest {
return app.NewEmbeddedManifest(appManifestData)
}
@@ -71,3 +69,15 @@ func LocalManifest() app.Manifest {
func RemoteManifest() app.Manifest {
return app.NewAPIServerManifest("advisor")
}
+
+var kindVersionToGoType = map[string]resource.Kind{
+ "Check/v0alpha1": v0alpha1.CheckKind(),
+ "CheckType/v0alpha1": v0alpha1.CheckTypeKind(),
+}
+
+// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists.
+// If there is no association for the provided Kind and Version, exists will return false.
+func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) {
+ goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)]
+ return goType, exists
+}
diff --git a/apps/advisor/pkg/app/checks/utils.go b/apps/advisor/pkg/app/checks/utils.go
index 0abcb8a603b..e58375ee64b 100644
--- a/apps/advisor/pkg/app/checks/utils.go
+++ b/apps/advisor/pkg/app/checks/utils.go
@@ -106,3 +106,25 @@ func SetStatusAnnotation(ctx context.Context, client resource.Client, obj resour
}},
}, resource.PatchOptions{}, obj)
}
+
+func SetAnnotations(ctx context.Context, client resource.Client, obj resource.Object, annotations map[string]string) error {
+ return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
+ Operations: []resource.PatchOperation{{
+ Operation: resource.PatchOpAdd,
+ Path: "/metadata/annotations",
+ Value: annotations,
+ }},
+ }, resource.PatchOptions{}, obj)
+}
+
+func SetStatus(ctx context.Context, client resource.Client, obj resource.Object, status any) error {
+ return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
+ Operations: []resource.PatchOperation{{
+ Operation: resource.PatchOpAdd,
+ Path: "/status",
+ Value: status,
+ }},
+ }, resource.PatchOptions{
+ Subresource: "status",
+ }, obj)
+}
diff --git a/apps/advisor/pkg/app/utils.go b/apps/advisor/pkg/app/utils.go
index 83dba0cf42a..cf090b67260 100644
--- a/apps/advisor/pkg/app/utils.go
+++ b/apps/advisor/pkg/app/utils.go
@@ -78,28 +78,21 @@ func processCheck(ctx context.Context, log logging.Logger, client resource.Clien
return fmt.Errorf("error running steps: %w", err)
}
- report := &advisorv0alpha1.CheckV0alpha1StatusReport{
+ report := &advisorv0alpha1.CheckReport{
Failures: failures,
Count: int64(len(items)),
}
+ c.Status.Report = *report
+ err = checks.SetStatus(ctx, client, obj, c.Status)
+ if err != nil {
+ return err
+ }
// Set the status annotation to processed and annotate the steps ignored
annotations := checks.AddAnnotations(ctx, obj, map[string]string{
checks.StatusAnnotation: checks.StatusAnnotationProcessed,
checks.IgnoreStepsAnnotationList: checkType.GetAnnotations()[checks.IgnoreStepsAnnotationList],
})
- return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
- Operations: []resource.PatchOperation{
- {
- Operation: resource.PatchOpAdd,
- Path: "/status/report",
- Value: *report,
- }, {
- Operation: resource.PatchOpAdd,
- Path: "/metadata/annotations",
- Value: annotations,
- },
- },
- }, resource.PatchOptions{}, obj)
+ return checks.SetAnnotations(ctx, client, obj, annotations)
}
func processCheckRetry(ctx context.Context, log logging.Logger, client resource.Client, typesClient resource.Client, obj resource.Object, check checks.Check) error {
@@ -157,7 +150,7 @@ func processCheckRetry(ctx context.Context, log logging.Logger, client resource.
}
}
// Pull failures from the report for the items to retry
- c.CheckStatus.Report.Failures = slices.DeleteFunc(c.CheckStatus.Report.Failures, func(f advisorv0alpha1.CheckReportFailure) bool {
+ c.Status.Report.Failures = slices.DeleteFunc(c.Status.Report.Failures, func(f advisorv0alpha1.CheckReportFailure) bool {
if f.ItemID == itemToRetry {
for _, newFailure := range failures {
if newFailure.StepID == f.StepID {
@@ -171,19 +164,13 @@ func processCheckRetry(ctx context.Context, log logging.Logger, client resource.
// Failure not in the list of items to retry, keep it
return false
})
+ err = checks.SetStatus(ctx, client, obj, c.Status)
+ if err != nil {
+ return err
+ }
// Delete the retry annotation to mark the check as processed
annotations := checks.DeleteAnnotations(ctx, obj, []string{checks.RetryAnnotation})
- return client.PatchInto(ctx, obj.GetStaticMetadata().Identifier(), resource.PatchRequest{
- Operations: []resource.PatchOperation{{
- Operation: resource.PatchOpAdd,
- Path: "/status/report",
- Value: c.CheckStatus.Report,
- }, {
- Operation: resource.PatchOpAdd,
- Path: "/metadata/annotations",
- Value: annotations,
- }},
- }, resource.PatchOptions{}, obj)
+ return checks.SetAnnotations(ctx, client, obj, annotations)
}
func runStepsInParallel(ctx context.Context, log logging.Logger, spec *advisorv0alpha1.CheckSpec, steps []checks.Step, items []any) ([]advisorv0alpha1.CheckReportFailure, error) {
diff --git a/apps/advisor/pkg/app/utils_test.go b/apps/advisor/pkg/app/utils_test.go
index dc8c635f6e8..df4b016db93 100644
--- a/apps/advisor/pkg/app/utils_test.go
+++ b/apps/advisor/pkg/app/utils_test.go
@@ -95,9 +95,9 @@ func TestProcessMultipleCheckItems(t *testing.T) {
err = processCheck(ctx, logging.DefaultLogger, client, typesClient, obj, check)
assert.NoError(t, err)
assert.Equal(t, checks.StatusAnnotationProcessed, obj.GetAnnotations()[checks.StatusAnnotation])
- r := client.lastValue.(advisorv0alpha1.CheckV0alpha1StatusReport)
- assert.Equal(t, r.Count, int64(100))
- assert.Len(t, r.Failures, 50)
+ r := client.values[0].(advisorv0alpha1.CheckStatus)
+ assert.Equal(t, r.Report.Count, int64(100))
+ assert.Len(t, r.Report.Failures, 50)
}
func TestProcessCheck_AlreadyProcessed(t *testing.T) {
@@ -231,7 +231,7 @@ func TestProcessCheckRetry_SkipMissingItem(t *testing.T) {
checks.RetryAnnotation: "item",
checks.StatusAnnotation: checks.StatusAnnotationProcessed,
})
- obj.CheckStatus.Report.Failures = []advisorv0alpha1.CheckReportFailure{
+ obj.Status.Report.Failures = []advisorv0alpha1.CheckReportFailure{
{
ItemID: "item",
StepID: "step",
@@ -254,7 +254,7 @@ func TestProcessCheckRetry_SkipMissingItem(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, checks.StatusAnnotationProcessed, obj.GetAnnotations()[checks.StatusAnnotation])
assert.Empty(t, obj.GetAnnotations()[checks.RetryAnnotation])
- assert.Empty(t, obj.CheckStatus.Report.Failures)
+ assert.Empty(t, obj.Status.Report.Failures)
}
func TestProcessCheckRetry_Success(t *testing.T) {
@@ -263,7 +263,7 @@ func TestProcessCheckRetry_Success(t *testing.T) {
checks.RetryAnnotation: "item",
checks.StatusAnnotation: checks.StatusAnnotationProcessed,
})
- obj.CheckStatus.Report.Failures = []advisorv0alpha1.CheckReportFailure{
+ obj.Status.Report.Failures = []advisorv0alpha1.CheckReportFailure{
{
ItemID: "item",
StepID: "step",
@@ -286,16 +286,16 @@ func TestProcessCheckRetry_Success(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, checks.StatusAnnotationProcessed, obj.GetAnnotations()[checks.StatusAnnotation])
assert.Empty(t, obj.GetAnnotations()[checks.RetryAnnotation])
- assert.Empty(t, obj.CheckStatus.Report.Failures)
+ assert.Empty(t, obj.Status.Report.Failures)
}
type mockClient struct {
resource.Client
- lastValue any
+ values []any
}
func (m *mockClient) PatchInto(ctx context.Context, id resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions, obj resource.Object) error {
- m.lastValue = req.Operations[0].Value
+ m.values = append(m.values, req.Operations[0].Value)
return nil
}
diff --git a/pkg/services/pluginsintegration/advisor/advisor.go b/pkg/services/pluginsintegration/advisor/advisor.go
index c9782de28bf..98989679aa8 100644
--- a/pkg/services/pluginsintegration/advisor/advisor.go
+++ b/pkg/services/pluginsintegration/advisor/advisor.go
@@ -86,7 +86,7 @@ func (s *Service) ReportSummary(ctx context.Context) (*ReportInfo, error) {
latestDatasourceCheck := findLatestCheck(checkList.GetItems(), datasourcecheck.CheckID)
reportInfo := &ReportInfo{}
if latestPluginCheck != nil {
- for _, failure := range latestPluginCheck.CheckStatus.Report.Failures {
+ for _, failure := range latestPluginCheck.Status.Report.Failures {
switch failure.StepID {
case plugincheck.UpdateStepID:
reportInfo.PluginsOutdated++
@@ -96,7 +96,7 @@ func (s *Service) ReportSummary(ctx context.Context) (*ReportInfo, error) {
}
}
if latestDatasourceCheck != nil {
- for _, failure := range latestDatasourceCheck.CheckStatus.Report.Failures {
+ for _, failure := range latestDatasourceCheck.Status.Report.Failures {
if failure.StepID == datasourcecheck.HealthCheckStepID {
reportInfo.DatasourcesUnhealthy++
}
diff --git a/pkg/services/pluginsintegration/advisor/advisor_test.go b/pkg/services/pluginsintegration/advisor/advisor_test.go
index c9c9b8d1609..5fce5f23f35 100644
--- a/pkg/services/pluginsintegration/advisor/advisor_test.go
+++ b/pkg/services/pluginsintegration/advisor/advisor_test.go
@@ -41,8 +41,8 @@ func TestService_ReportSummary(t *testing.T) {
checks.TypeLabel: plugincheck.CheckID,
},
},
- CheckStatus: advisorv0alpha1.CheckStatus{
- Report: advisorv0alpha1.CheckV0alpha1StatusReport{
+ Status: advisorv0alpha1.CheckStatus{
+ Report: advisorv0alpha1.CheckReport{
Failures: []advisorv0alpha1.CheckReportFailure{
{StepID: plugincheck.UpdateStepID},
},
@@ -56,8 +56,8 @@ func TestService_ReportSummary(t *testing.T) {
checks.TypeLabel: plugincheck.CheckID,
},
},
- CheckStatus: advisorv0alpha1.CheckStatus{
- Report: advisorv0alpha1.CheckV0alpha1StatusReport{
+ Status: advisorv0alpha1.CheckStatus{
+ Report: advisorv0alpha1.CheckReport{
Failures: []advisorv0alpha1.CheckReportFailure{
{StepID: plugincheck.UpdateStepID},
{StepID: plugincheck.DeprecationStepID},
@@ -72,8 +72,8 @@ func TestService_ReportSummary(t *testing.T) {
checks.TypeLabel: datasourcecheck.CheckID,
},
},
- CheckStatus: advisorv0alpha1.CheckStatus{
- Report: advisorv0alpha1.CheckV0alpha1StatusReport{
+ Status: advisorv0alpha1.CheckStatus{
+ Report: advisorv0alpha1.CheckReport{
Failures: []advisorv0alpha1.CheckReportFailure{
{StepID: datasourcecheck.HealthCheckStepID},
{StepID: datasourcecheck.HealthCheckStepID},
diff --git a/pkg/tests/apis/openapi_snapshots/advisor.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/advisor.grafana.app-v0alpha1.json
index 77879dc0fc4..03fbbfc37c3 100644
--- a/pkg/tests/apis/openapi_snapshots/advisor.grafana.app-v0alpha1.json
+++ b/pkg/tests/apis/openapi_snapshots/advisor.grafana.app-v0alpha1.json
@@ -2303,6 +2303,7 @@
]
},
"spec": {
+ "description": "Spec is the spec of the Check",
"default": {},
"allOf": [
{
@@ -2389,6 +2390,33 @@
}
]
},
+ "com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckReport": {
+ "type": "object",
+ "required": [
+ "count",
+ "failures"
+ ],
+ "properties": {
+ "count": {
+ "description": "Number of elements analyzed",
+ "type": "integer",
+ "format": "int64",
+ "default": 0
+ },
+ "failures": {
+ "description": "List of failures",
+ "type": "array",
+ "items": {
+ "default": {},
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckReportFailure"
+ }
+ ]
+ }
+ }
+ }
+ },
"com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckReportFailure": {
"type": "object",
"required": [
@@ -2479,7 +2507,7 @@
"default": {},
"allOf": [
{
- "$ref": "#/components/schemas/com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckV0alpha1StatusReport"
+ "$ref": "#/components/schemas/com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckReport"
}
]
}
@@ -2510,6 +2538,7 @@
]
},
"spec": {
+ "description": "Spec is the spec of the CheckType",
"default": {},
"allOf": [
{
@@ -2682,33 +2711,6 @@
}
}
},
- "com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckV0alpha1StatusReport": {
- "type": "object",
- "required": [
- "count",
- "failures"
- ],
- "properties": {
- "count": {
- "description": "Number of elements analyzed",
- "type": "integer",
- "format": "int64",
- "default": 0
- },
- "failures": {
- "description": "List of failures",
- "type": "array",
- "items": {
- "default": {},
- "allOf": [
- {
- "$ref": "#/components/schemas/com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckReportFailure"
- }
- ]
- }
- }
- }
- },
"com.github.grafana.grafana.apps.advisor.pkg.apis.advisor.v0alpha1.CheckstatusOperatorState": {
"type": "object",
"required": [
From b6c4788c2ad5502cd36a2a3170ea6b31580328a0 Mon Sep 17 00:00:00 2001
From: Matheus Macabu
Date: Thu, 10 Jul 2025 10:10:57 +0200
Subject: [PATCH 06/33] Auth: Add functional option for static requester
methods (#107581)
* Auth: Add functional option for static requester methods
Initially supporting WithServiceIdentityName to set a ServiceIdentity
inside the Claims.Rest object, so that Secrets Manager can parse
the service requesting secret decryption.
On Secret creation, the service will have to pass its identity
(which is a freeform string) to the SecureValues' Decrypters object.
This field gates which services are allowed to decrypt the SecureValue.
And upon decryption, the service should build a static identity with
that same service identity name when calling the decrypt service.
* StaticRequester: Put secret decrypt permission in access token claims
* StaticRequester: Inline getTokenPermissions function
---
pkg/apimachinery/identity/context.go | 67 ++++++++++++++---------
pkg/apimachinery/identity/context_test.go | 46 ++++++++++++++++
2 files changed, 87 insertions(+), 26 deletions(-)
diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go
index 4e849dbba3c..f98738db58d 100644
--- a/pkg/apimachinery/identity/context.go
+++ b/pkg/apimachinery/identity/context.go
@@ -36,8 +36,22 @@ const (
serviceNameForProvisioning = "provisioning"
)
-func newInternalIdentity(name string, namespace string, orgID int64) Requester {
- return &StaticRequester{
+type IdentityOpts func(*StaticRequester)
+
+// WithServiceIdentityName sets the `StaticRequester.AccessTokenClaims.Rest.ServiceIdentity` field to the provided name.
+// This is so far only used by Secrets Manager to identify and gate the service decrypting a secret.
+func WithServiceIdentityName(name string) IdentityOpts {
+ return func(r *StaticRequester) {
+ r.AccessTokenClaims.Rest.ServiceIdentity = name
+ }
+}
+
+func newInternalIdentity(name string, namespace string, orgID int64, opts ...IdentityOpts) Requester {
+ // Create a copy of the ServiceIdentityClaims to avoid modifying the global one.
+ // Some of the options might mutate it.
+ claimsCopy := *ServiceIdentityClaims
+
+ staticRequester := &StaticRequester{
Type: types.TypeAccessPolicy,
Name: name,
UserUID: name,
@@ -50,37 +64,43 @@ func newInternalIdentity(name string, namespace string, orgID int64) Requester {
Permissions: map[int64]map[string][]string{
orgID: serviceIdentityPermissions,
},
- AccessTokenClaims: ServiceIdentityClaims,
+ AccessTokenClaims: &claimsCopy,
}
+
+ for _, opt := range opts {
+ opt(staticRequester)
+ }
+
+ return staticRequester
}
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
// static permissions so it can be used in legacy code paths.
-func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
- r := newInternalIdentity(serviceName, "*", orgID)
+func WithServiceIdentity(ctx context.Context, orgID int64, opts ...IdentityOpts) (context.Context, Requester) {
+ r := newInternalIdentity(serviceName, "*", orgID, opts...)
return WithRequester(ctx, r), r
}
-func WithProvisioningIdentity(ctx context.Context, namespace string) (context.Context, Requester, error) {
+func WithProvisioningIdentity(ctx context.Context, namespace string, opts ...IdentityOpts) (context.Context, Requester, error) {
ns, err := types.ParseNamespace(namespace)
if err != nil {
return nil, nil, err
}
- r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID)
+ r := newInternalIdentity(serviceNameForProvisioning, ns.Value, ns.OrgID, opts...)
return WithRequester(ctx, r), r, nil
}
// WithServiceIdentityContext sets an identity representing the service itself in context.
-func WithServiceIdentityContext(ctx context.Context, orgID int64) context.Context {
- ctx, _ = WithServiceIdentity(ctx, orgID)
+func WithServiceIdentityContext(ctx context.Context, orgID int64, opts ...IdentityOpts) context.Context {
+ ctx, _ = WithServiceIdentity(ctx, orgID, opts...)
return ctx
}
// WithServiceIdentityFN calls provided closure with an context contaning the identity of the service.
-func WithServiceIdentityFn[T any](ctx context.Context, orgID int64, fn func(ctx context.Context) (T, error)) (T, error) {
- return fn(WithServiceIdentityContext(ctx, orgID))
+func WithServiceIdentityFn[T any](ctx context.Context, orgID int64, fn func(ctx context.Context) (T, error), opts ...IdentityOpts) (T, error) {
+ return fn(WithServiceIdentityContext(ctx, orgID, opts...))
}
func getWildcardPermissions(actions ...string) map[string][]string {
@@ -91,14 +111,6 @@ func getWildcardPermissions(actions ...string) map[string][]string {
return permissions
}
-func getTokenPermissions(groups ...string) []string {
- out := make([]string, 0, len(groups))
- for _, group := range groups {
- out = append(out, group+":*")
- }
- return out
-}
-
// serviceIdentityPermissions is a list of wildcard permissions for provided actions.
// We should add every action required "internally" here.
var serviceIdentityPermissions = getWildcardPermissions(
@@ -121,13 +133,16 @@ var serviceIdentityPermissions = getWildcardPermissions(
"serviceaccounts:read", // serviceaccounts.ActionRead,
)
-var serviceIdentityTokenPermissions = getTokenPermissions(
- "folder.grafana.app",
- "dashboard.grafana.app",
- "secret.grafana.app",
- "query.grafana.app",
- "iam.grafana.app",
-)
+var serviceIdentityTokenPermissions = []string{
+ "folder.grafana.app:*",
+ "dashboard.grafana.app:*",
+ "secret.grafana.app:*",
+ "query.grafana.app:*",
+ "iam.grafana.app:*",
+
+ // Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions.
+ "secret.grafana.app/securevalues:decrypt",
+}
var ServiceIdentityClaims = &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{
diff --git a/pkg/apimachinery/identity/context_test.go b/pkg/apimachinery/identity/context_test.go
index 87f31a17763..10c043517d7 100644
--- a/pkg/apimachinery/identity/context_test.go
+++ b/pkg/apimachinery/identity/context_test.go
@@ -4,6 +4,7 @@ import (
"context"
"testing"
+ "github.com/grafana/authlib/authn"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -24,3 +25,48 @@ func TestRequesterFromContext(t *testing.T) {
require.Equal(t, expected.GetUID(), actual.GetUID())
})
}
+
+func TestWithServiceIdentity(t *testing.T) {
+ t.Run("with a custom service identity name", func(t *testing.T) {
+ customName := "custom-service"
+ orgID := int64(1)
+ ctx, requester := identity.WithServiceIdentity(context.Background(), orgID, identity.WithServiceIdentityName(customName))
+ require.NotNil(t, requester)
+ require.Equal(t, orgID, requester.GetOrgID())
+ require.Equal(t, customName, requester.GetExtra()[string(authn.ServiceIdentityKey)][0])
+ require.Contains(t, requester.GetTokenPermissions(), "secret.grafana.app/securevalues:decrypt")
+
+ fromCtx, err := identity.GetRequester(ctx)
+ require.NoError(t, err)
+ require.Equal(t, customName, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)][0])
+
+ // Reuse the context but create another identity on top with a different name and org ID
+ anotherCustomName := "another-custom-service"
+ anotherOrgID := int64(2)
+ ctx2 := identity.WithServiceIdentityContext(ctx, anotherOrgID, identity.WithServiceIdentityName(anotherCustomName))
+
+ fromCtx, err = identity.GetRequester(ctx2)
+ require.NoError(t, err)
+ require.Equal(t, anotherOrgID, fromCtx.GetOrgID())
+ require.Equal(t, anotherCustomName, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)][0])
+
+ // Reuse the context but create another identity without a custom name
+ ctx3, requester := identity.WithServiceIdentity(ctx2, 1)
+ require.NotNil(t, requester)
+ require.Empty(t, requester.GetExtra()[string(authn.ServiceIdentityKey)])
+
+ fromCtx, err = identity.GetRequester(ctx3)
+ require.NoError(t, err)
+ require.Empty(t, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)])
+ })
+
+ t.Run("without a custom service identity name", func(t *testing.T) {
+ ctx, requester := identity.WithServiceIdentity(context.Background(), 1)
+ require.NotNil(t, requester)
+ require.Empty(t, requester.GetExtra()[string(authn.ServiceIdentityKey)])
+
+ fromCtx, err := identity.GetRequester(ctx)
+ require.NoError(t, err)
+ require.Empty(t, fromCtx.GetExtra()[string(authn.ServiceIdentityKey)])
+ })
+}
From fccda2440e8121a273c5b888571d1eccce4e9aa8 Mon Sep 17 00:00:00 2001
From: Jay Clifford <45856600+Jayclifford345@users.noreply.github.com>
Date: Thu, 10 Jul 2025 10:16:47 +0100
Subject: [PATCH 07/33] ExtensionSidebar: Render multiple sidebar buttons in
topnav (#107875)
* feat: modified toolbar item so buttons render invidually
* added icon for investigations
* Update public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx
Co-authored-by: Sven Grossmann
---------
Co-authored-by: Sven Grossmann
---
.../ExtensionToolbarItem.test.tsx | 37 +++++
.../ExtensionSidebar/ExtensionToolbarItem.tsx | 129 ++++++++----------
.../ExtensionToolbarItemButton.tsx | 2 +
3 files changed, 96 insertions(+), 72 deletions(-)
diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx
index 2b468d9f4c2..45b2c662cc7 100644
--- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx
+++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx
@@ -229,4 +229,41 @@ describe('ExtensionToolbarItem', () => {
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
});
+
+ it('should render individual buttons when multiple plugins are available', async () => {
+ const plugin1Meta = {
+ pluginId: 'grafana-investigations-app',
+ addedComponents: [{ ...mockComponent, title: 'Investigations' }],
+ };
+
+ const plugin2Meta = {
+ pluginId: 'grafana-assistant-app',
+ addedComponents: [{ ...mockComponent, title: 'Assistant' }],
+ };
+
+ (usePluginLinks as jest.Mock).mockReturnValue({
+ links: [
+ { pluginId: plugin1Meta.pluginId, title: plugin1Meta.addedComponents[0].title },
+ { pluginId: plugin2Meta.pluginId, title: plugin2Meta.addedComponents[0].title },
+ ],
+ isLoading: false,
+ });
+
+ (getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
+ new Map([
+ [plugin1Meta.pluginId, plugin1Meta],
+ [plugin2Meta.pluginId, plugin2Meta],
+ ])
+ );
+
+ setup();
+
+ // Should render two separate buttons, not a dropdown
+ const buttons = screen.getAllByTestId(/extension-toolbar-button-open/);
+ expect(buttons).toHaveLength(2);
+
+ // Each button should have the correct title
+ expect(buttons[0]).toHaveAttribute('aria-label', 'Open Investigations');
+ expect(buttons[1]).toHaveAttribute('aria-label', 'Open Assistant');
+ });
});
diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx
index 02cad31ea91..50fe56630af 100644
--- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx
+++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx
@@ -1,3 +1,4 @@
+import { ExtensionInfo } from '@grafana/data';
import { Dropdown, Menu } from '@grafana/ui';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
@@ -9,91 +10,75 @@ import {
} from './ExtensionSidebarProvider';
import { ExtensionToolbarItemButton } from './ExtensionToolbarItemButton';
-export function ExtensionToolbarItem() {
- const { availableComponents, dockedComponentId, setDockedComponentId, isOpen, isEnabled } =
- useExtensionSidebarContext();
+type ComponentWithPluginId = ExtensionInfo & { pluginId: string };
- let dockedComponentTitle = '';
- let dockedPluginId = '';
- if (dockedComponentId) {
- const dockedComponent = getComponentMetaFromComponentId(dockedComponentId);
- if (dockedComponent) {
- dockedComponentTitle = dockedComponent.componentTitle;
- dockedPluginId = dockedComponent.pluginId;
- }
- }
+export function ExtensionToolbarItem() {
+ const { availableComponents, dockedComponentId, setDockedComponentId, isEnabled } = useExtensionSidebarContext();
if (!isEnabled || availableComponents.size === 0) {
return null;
}
- // get a flat list of all components with their pluginId
- const components = Array.from(availableComponents.entries()).flatMap(([pluginId, { addedComponents }]) =>
- addedComponents.map((c) => ({ ...c, pluginId }))
- );
+ const dockedMeta = dockedComponentId ? getComponentMetaFromComponentId(dockedComponentId) : null;
- if (components.length === 0) {
- return null;
- }
+ const renderPluginButton = (pluginId: string, components: ComponentWithPluginId[]) => {
+ if (components.length === 1) {
+ const component = components[0];
+ const componentId = getComponentIdFromComponentMeta(pluginId, component);
+ const isActive = dockedComponentId === componentId;
- if (components.length === 1) {
- return (
- <>
+ return (
{
- if (isOpen) {
- setDockedComponentId(undefined);
- } else {
- setDockedComponentId(getComponentIdFromComponentMeta(components[0].pluginId, components[0]));
- }
- }}
- pluginId={components[0].pluginId}
+ key={pluginId}
+ isOpen={isActive}
+ title={component.title}
+ onClick={() => setDockedComponentId(isActive ? undefined : componentId)}
+ pluginId={pluginId}
/>
-
- >
- );
- }
+ );
+ }
+
+ const isPluginActive = dockedMeta?.pluginId === pluginId;
+ const MenuItems = (
+
+ );
+
+ return isPluginActive ? (
+ setDockedComponentId(undefined)}
+ pluginId={pluginId}
+ />
+ ) : (
+
+
+
+ );
+ };
- const MenuItems = (
-
- );
return (
<>
- {isOpen ? (
- {
- if (isOpen) {
- setDockedComponentId(undefined);
- }
- }}
- pluginId={dockedPluginId}
- />
- ) : (
-
-
-
+ {/* renders a single `ExtensionToolbarItemButton` for each plugin; if a plugin has multiple components, it renders them inside a `Dropdown` */}
+ {Array.from(availableComponents.entries()).map(
+ ([pluginId, { addedComponents }]: [string, { addedComponents: ExtensionInfo[] }]) =>
+ renderPluginButton(
+ pluginId,
+ addedComponents.map((c: ExtensionInfo) => ({ ...c, pluginId }))
+ )
)}
>
diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx
index 44e33526095..3dcc53730a7 100644
--- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx
+++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx
@@ -16,6 +16,8 @@ function getPluginIcon(pluginId?: string): string {
switch (pluginId) {
case 'grafana-grafanadocsplugin-app':
return 'book';
+ case 'grafana-investigations-app':
+ return 'eye';
default:
return 'ai-sparkle';
}
From befc947feee3b140855a5c57b63b90b3798941c1 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 09:28:05 +0000
Subject: [PATCH 08/33] Update dependency glob to v11.0.3 (#107915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package.json | 2 +-
packages/grafana-plugin-configs/package.json | 2 +-
.../app/plugins/datasource/tempo/package.json | 2 +-
yarn.lock | 68 +++++++++++--------
4 files changed, 43 insertions(+), 31 deletions(-)
diff --git a/package.json b/package.json
index f22f1b7196e..01192b61a04 100644
--- a/package.json
+++ b/package.json
@@ -201,7 +201,7 @@
"expose-loader": "5.0.1",
"fishery": "^2.2.2",
"fork-ts-checker-webpack-plugin": "9.0.2",
- "glob": "11.0.1",
+ "glob": "11.0.3",
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"http-server": "14.1.1",
diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json
index ffd590576f5..cf0cc967eaa 100644
--- a/packages/grafana-plugin-configs/package.json
+++ b/packages/grafana-plugin-configs/package.json
@@ -16,7 +16,7 @@
"eslint": "9.19.0",
"eslint-webpack-plugin": "4.2.0",
"fork-ts-checker-webpack-plugin": "9.0.2",
- "glob": "11.0.1",
+ "glob": "11.0.3",
"imports-loader": "^5.0.0",
"replace-in-file-webpack-plugin": "1.0.6",
"swc-loader": "0.2.6",
diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json
index fa1710c1d6b..469228c1ce9 100644
--- a/public/app/plugins/datasource/tempo/package.json
+++ b/public/app/plugins/datasource/tempo/package.json
@@ -51,7 +51,7 @@
"@types/react-dom": "18.3.5",
"@types/semver": "7.7.0",
"@types/uuid": "10.0.0",
- "glob": "11.0.1",
+ "glob": "11.0.3",
"react-select-event": "5.5.1",
"ts-node": "10.9.2",
"typescript": "5.8.3",
diff --git a/yarn.lock b/yarn.lock
index 1c0981adc93..5d851cf5fa0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2938,7 +2938,7 @@ __metadata:
"@types/uuid": "npm:10.0.0"
buffer: "npm:6.0.3"
events: "npm:3.3.0"
- glob: "npm:11.0.1"
+ glob: "npm:11.0.3"
i18next: "npm:^25.0.0"
lodash: "npm:4.17.21"
lru-cache: "npm:11.1.0"
@@ -3381,7 +3381,7 @@ __metadata:
eslint: "npm:9.19.0"
eslint-webpack-plugin: "npm:4.2.0"
fork-ts-checker-webpack-plugin: "npm:9.0.2"
- glob: "npm:11.0.1"
+ glob: "npm:11.0.3"
imports-loader: "npm:^5.0.0"
replace-in-file-webpack-plugin: "npm:1.0.6"
swc-loader: "npm:0.2.6"
@@ -3994,6 +3994,22 @@ __metadata:
languageName: node
linkType: hard
+"@isaacs/balanced-match@npm:^4.0.1":
+ version: 4.0.1
+ resolution: "@isaacs/balanced-match@npm:4.0.1"
+ checksum: 10/102fbc6d2c0d5edf8f6dbf2b3feb21695a21bc850f11bc47c4f06aa83bd8884fde3fe9d6d797d619901d96865fdcb4569ac2a54c937992c48885c5e3d9967fe8
+ languageName: node
+ linkType: hard
+
+"@isaacs/brace-expansion@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "@isaacs/brace-expansion@npm:5.0.0"
+ dependencies:
+ "@isaacs/balanced-match": "npm:^4.0.1"
+ checksum: 10/cf3b7f206aff12128214a1df764ac8cdbc517c110db85249b945282407e3dfc5c6e66286383a7c9391a059fc8e6e6a8ca82262fc9d2590bd615376141fbebd2d
+ languageName: node
+ linkType: hard
+
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
@@ -17239,13 +17255,13 @@ __metadata:
languageName: node
linkType: hard
-"foreground-child@npm:^3.1.0":
- version: 3.1.1
- resolution: "foreground-child@npm:3.1.1"
+"foreground-child@npm:^3.1.0, foreground-child@npm:^3.3.1":
+ version: 3.3.1
+ resolution: "foreground-child@npm:3.3.1"
dependencies:
- cross-spawn: "npm:^7.0.0"
+ cross-spawn: "npm:^7.0.6"
signal-exit: "npm:^4.0.1"
- checksum: 10/087edd44857d258c4f73ad84cb8df980826569656f2550c341b27adf5335354393eec24ea2fabd43a253233fb27cee177ebe46bd0b7ea129c77e87cb1e9936fb
+ checksum: 10/427b33f997a98073c0424e5c07169264a62cda806d8d2ded159b5b903fdfc8f0a1457e06b5fc35506497acb3f1e353f025edee796300209ac6231e80edece835
languageName: node
linkType: hard
@@ -17882,19 +17898,19 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:11.0.1, glob@npm:^11.0.0":
- version: 11.0.1
- resolution: "glob@npm:11.0.1"
+"glob@npm:11.0.3, glob@npm:^11.0.0":
+ version: 11.0.3
+ resolution: "glob@npm:11.0.3"
dependencies:
- foreground-child: "npm:^3.1.0"
- jackspeak: "npm:^4.0.1"
- minimatch: "npm:^10.0.0"
+ foreground-child: "npm:^3.3.1"
+ jackspeak: "npm:^4.1.1"
+ minimatch: "npm:^10.0.3"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^2.0.0"
bin:
glob: dist/esm/bin.mjs
- checksum: 10/57b12a05cc25f1c38f3b24cf6ea7a8bacef11e782c4b9a8c5b0bef3e6c5bcb8c4548cb31eb4115592e0490a024c1bde7359c470565608dd061d3b21179740457
+ checksum: 10/2ae536c1360c0266b523b2bfa6aadc10144a8b7e08869b088e37ac3c27cd30774f82e4bfb291cde796776e878f9e13200c7ff44010eb7054e00f46f649397893
languageName: node
linkType: hard
@@ -18303,7 +18319,7 @@ __metadata:
file-saver: "npm:2.0.5"
fishery: "npm:^2.2.2"
fork-ts-checker-webpack-plugin: "npm:9.0.2"
- glob: "npm:11.0.1"
+ glob: "npm:11.0.3"
history: "npm:4.10.1"
html-loader: "npm:5.1.0"
html-webpack-plugin: "npm:5.6.3"
@@ -20465,16 +20481,12 @@ __metadata:
languageName: node
linkType: hard
-"jackspeak@npm:^4.0.1":
- version: 4.0.1
- resolution: "jackspeak@npm:4.0.1"
+"jackspeak@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "jackspeak@npm:4.1.1"
dependencies:
"@isaacs/cliui": "npm:^8.0.2"
- "@pkgjs/parseargs": "npm:^0.11.0"
- dependenciesMeta:
- "@pkgjs/parseargs":
- optional: true
- checksum: 10/b20dc0df0dbb2903e4d540ae68308ec7d1dd60944b130e867e218c98b5d77481d65ea734b6c81c812d481500076e8b3fdfccfb38fc81cb1acf165e853da3e26c
+ checksum: 10/ffceb270ec286841f48413bfb4a50b188662dfd599378ce142b6540f3f0a66821dc9dcb1e9ebc55c6c3b24dc2226c96e5819ba9bd7a241bd29031b61911718c7
languageName: node
linkType: hard
@@ -22767,12 +22779,12 @@ __metadata:
languageName: node
linkType: hard
-"minimatch@npm:^10.0.0":
- version: 10.0.1
- resolution: "minimatch@npm:10.0.1"
+"minimatch@npm:^10.0.3":
+ version: 10.0.3
+ resolution: "minimatch@npm:10.0.3"
dependencies:
- brace-expansion: "npm:^2.0.1"
- checksum: 10/082e7ccbc090d5f8c4e4e029255d5a1d1e3af37bda837da2b8b0085b1503a1210c91ac90d9ebfe741d8a5f286ece820a1abb4f61dc1f82ce602a055d461d93f3
+ "@isaacs/brace-expansion": "npm:^5.0.0"
+ checksum: 10/d5b8b2538b367f2cfd4aeef27539fddeee58d1efb692102b848e4a968a09780a302c530eb5aacfa8c57f7299155fb4b4e85219ad82664dcef5c66f657111d9b8
languageName: node
linkType: hard
From b6dd08da2f2bbcc3cbbd42f6e5e091dbe034ca65 Mon Sep 17 00:00:00 2001
From: Georges Chaudy
Date: Thu, 10 Jul 2025 11:34:36 +0200
Subject: [PATCH 09/33] unistore: fix delete and db closed in kv store
(#107918)
* fix delete and db closed
* fix tests
---
pkg/storage/unified/resource/kv.go | 17 +++++++++++
pkg/storage/unified/resource/server.go | 7 +++++
.../unified/resource/storage_backend.go | 10 +++++--
.../unified/resource/storage_backend_test.go | 7 +++++
.../unified/testing/storage_backend.go | 30 +++++++++++++++++--
.../unified/testing/storage_backend_test.go | 1 -
6 files changed, 66 insertions(+), 6 deletions(-)
diff --git a/pkg/storage/unified/resource/kv.go b/pkg/storage/unified/resource/kv.go
index e4af06a9aa1..21343984c8c 100644
--- a/pkg/storage/unified/resource/kv.go
+++ b/pkg/storage/unified/resource/kv.go
@@ -68,6 +68,10 @@ func NewBadgerKV(db *badger.DB) *badgerKV {
}
func (k *badgerKV) Get(ctx context.Context, section string, key string) (KVObject, error) {
+ if k.db.IsClosed() {
+ return KVObject{}, fmt.Errorf("database is closed")
+ }
+
txn := k.db.NewTransaction(false)
defer txn.Discard()
@@ -101,6 +105,10 @@ func (k *badgerKV) Get(ctx context.Context, section string, key string) (KVObjec
}
func (k *badgerKV) Save(ctx context.Context, section string, key string, value io.Reader) error {
+ if k.db.IsClosed() {
+ return fmt.Errorf("database is closed")
+ }
+
if section == "" {
return fmt.Errorf("section is required")
}
@@ -123,6 +131,10 @@ func (k *badgerKV) Save(ctx context.Context, section string, key string, value i
}
func (k *badgerKV) Delete(ctx context.Context, section string, key string) error {
+ if k.db.IsClosed() {
+ return fmt.Errorf("database is closed")
+ }
+
if section == "" {
return fmt.Errorf("section is required")
}
@@ -149,6 +161,11 @@ func (k *badgerKV) Delete(ctx context.Context, section string, key string) error
}
func (k *badgerKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] {
+ if k.db.IsClosed() {
+ return func(yield func(string, error) bool) {
+ yield("", fmt.Errorf("database is closed"))
+ }
+ }
if section == "" {
return func(yield func(string, error) bool) {
yield("", fmt.Errorf("section is required"))
diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go
index 35dc9de717b..00567a3774c 100644
--- a/pkg/storage/unified/resource/server.go
+++ b/pkg/storage/unified/resource/server.go
@@ -782,6 +782,11 @@ func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resource
return nil, apierrors.NewBadRequest(
fmt.Sprintf("unable to read previous object, %v", err))
}
+ oldObj, err := utils.MetaAccessor(marker)
+ if err != nil {
+ return nil, err
+ }
+
obj, err := utils.MetaAccessor(marker)
if err != nil {
return nil, err
@@ -793,6 +798,8 @@ func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resource
obj.SetUpdatedBy(user.GetUID())
obj.SetGeneration(utils.DeletedGeneration)
obj.SetAnnotation(utils.AnnoKeyKubectlLastAppliedConfig, "") // clears it
+ event.ObjectOld = oldObj
+ event.Object = obj
event.Value, err = marker.MarshalJSON()
if err != nil {
return nil, apierrors.NewBadRequest(
diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go
index 53a898e50b2..dff33f63b36 100644
--- a/pkg/storage/unified/resource/storage_backend.go
+++ b/pkg/storage/unified/resource/storage_backend.go
@@ -63,6 +63,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
}
rv := k.snowflake.Generate().Int64()
+ obj := event.Object
// Write data.
var action DataAction
switch event.Type {
@@ -87,10 +88,15 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
action = DataActionUpdated
case resourcepb.WatchEvent_DELETED:
action = DataActionDeleted
+ obj = event.ObjectOld
default:
return 0, fmt.Errorf("invalid event type: %d", event.Type)
}
+ if obj == nil {
+ return 0, fmt.Errorf("object is nil")
+ }
+
// Build the search document
doc, err := k.builder.BuildDocument(ctx, event.Key, rv, event.Value)
if err != nil {
@@ -119,7 +125,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
Name: event.Key.Name,
ResourceVersion: rv,
Action: action,
- Folder: event.Object.GetFolder(),
+ Folder: obj.GetFolder(),
},
Value: MetaData{
IndexableDocument: *doc,
@@ -137,7 +143,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
Name: event.Key.Name,
ResourceVersion: rv,
Action: action,
- Folder: event.Object.GetFolder(),
+ Folder: obj.GetFolder(),
PreviousRV: event.PreviousRV,
})
if err != nil {
diff --git a/pkg/storage/unified/resource/storage_backend_test.go b/pkg/storage/unified/resource/storage_backend_test.go
index 899f7b2de25..bb1288458d0 100644
--- a/pkg/storage/unified/resource/storage_backend_test.go
+++ b/pkg/storage/unified/resource/storage_backend_test.go
@@ -72,6 +72,7 @@ func TestKvStorageBackend_WriteEvent_Success(t *testing.T) {
},
Value: objectToJSONBytes(t, testObj),
Object: metaAccessor,
+ ObjectOld: metaAccessor,
PreviousRV: 100,
}
@@ -799,6 +800,8 @@ func TestKvStorageBackend_ListTrash_Success(t *testing.T) {
// Delete the resource
writeEvent.Type = resourcepb.WatchEvent_DELETED
writeEvent.PreviousRV = rv1
+ writeEvent.Object = metaAccessor
+ writeEvent.ObjectOld = metaAccessor
rv2, err := backend.WriteEvent(ctx, writeEvent)
require.NoError(t, err)
@@ -989,8 +992,12 @@ func writeObject(t *testing.T, backend *kvStorageBackend, obj *unstructured.Unst
},
Value: objectToJSONBytes(t, obj),
Object: metaAccessor,
+ ObjectOld: metaAccessor,
PreviousRV: previousRV,
}
+ if eventType == resourcepb.WatchEvent_ADDED {
+ writeEvent.ObjectOld = nil
+ }
return backend.WriteEvent(context.Background(), writeEvent)
}
diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go
index 2f369c6d233..e3314376715 100644
--- a/pkg/storage/unified/testing/storage_backend.go
+++ b/pkg/storage/unified/testing/storage_backend.go
@@ -1106,7 +1106,7 @@ func writeEvent(ctx context.Context, store resource.StorageBackend, name string,
}
meta.SetFolder(options.Folder)
- return store.WriteEvent(ctx, resource.WriteEvent{
+ event := resource.WriteEvent{
Type: action,
Value: options.Value,
GUID: uuid.New().String(),
@@ -1116,8 +1116,32 @@ func writeEvent(ctx context.Context, store resource.StorageBackend, name string,
Resource: options.Resource,
Name: name,
},
- Object: meta,
- })
+ }
+ switch action {
+ case resourcepb.WatchEvent_DELETED:
+ event.ObjectOld = meta
+
+ obj, err := utils.MetaAccessor(res)
+ if err != nil {
+ return 0, err
+ }
+ now := metav1.Now()
+ obj.SetDeletionTimestamp(&now)
+ obj.SetUpdatedTimestamp(&now.Time)
+ obj.SetManagedFields(nil)
+ obj.SetFinalizers(nil)
+ obj.SetGeneration(utils.DeletedGeneration)
+ obj.SetAnnotation(utils.AnnoKeyKubectlLastAppliedConfig, "") // clears it
+ event.Object = obj
+ case resourcepb.WatchEvent_ADDED:
+ event.Object = meta
+ case resourcepb.WatchEvent_MODIFIED:
+ event.Object = meta //
+ event.ObjectOld = meta
+ default:
+ panic(fmt.Sprintf("invalid action: %s", action))
+ }
+ return store.WriteEvent(ctx, event)
}
func newServer(t *testing.T, b resource.StorageBackend) resource.ResourceServer {
diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go
index c0be0065c2f..a1da3b82e72 100644
--- a/pkg/storage/unified/testing/storage_backend_test.go
+++ b/pkg/storage/unified/testing/storage_backend_test.go
@@ -11,7 +11,6 @@ import (
)
func TestBadgerKVStorageBackend(t *testing.T) {
- t.Skip("failing with 'panic: DB Closed'")
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
opts := badger.DefaultOptions("").WithInMemory(true).WithLogger(nil)
db, err := badger.Open(opts)
From b41b233d7dab2db48af06cce16141879484752a8 Mon Sep 17 00:00:00 2001
From: Hugo Kiyodi Oshiro
Date: Thu, 10 Jul 2025 12:12:12 +0200
Subject: [PATCH 10/33] Plugins: Levitate workflow improvements on forks
(#107832)
---
scripts/check-breaking-changes.sh | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/scripts/check-breaking-changes.sh b/scripts/check-breaking-changes.sh
index 7ce7a28ea94..dc1e5741d6b 100755
--- a/scripts/check-breaking-changes.sh
+++ b/scripts/check-breaking-changes.sh
@@ -68,7 +68,8 @@ mkdir -p ./levitate
echo "$GITHUB_LEVITATE_MARKDOWN" >./levitate/levitate.md
if [[ "$IS_FORK" == "true" ]]; then
- cat ./levitate/levitate.md
+ cat ./levitate/levitate.md >> "$GITHUB_STEP_SUMMARY"
+ exit $EXIT_CODE
fi
# We will exit the workflow accordingly at another step
From bd81243bbbc36896ce0f6f4438bc95f39b91fda3 Mon Sep 17 00:00:00 2001
From: Stephanie Hingtgen
Date: Mon, 30 Jun 2025 17:38:23 -0600
Subject: [PATCH 11/33] Git sync: Implement folder deletion
---
pkg/registry/apis/provisioning/files.go | 6 -
.../apis/provisioning/repository/local.go | 9 +-
.../provisioning/repository/local_test.go | 35 +++++
.../apis/provisioning/resources/dualwriter.go | 147 +++++++++++++++++-
.../apis/provisioning/provisioning_test.go | 124 +++++++++++++++
.../provisioning/testdata/timeline-demo.json | 1 +
6 files changed, 313 insertions(+), 9 deletions(-)
create mode 120000 pkg/tests/apis/provisioning/testdata/timeline-demo.json
diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go
index 619bad795fe..a0548e1055b 100644
--- a/pkg/registry/apis/provisioning/files.go
+++ b/pkg/registry/apis/provisioning/files.go
@@ -128,12 +128,6 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
return
}
- // TODO: Implement folder delete
- if r.Method == http.MethodDelete && isDir {
- responder.Error(apierrors.NewBadRequest("folder navigation not yet supported"))
- return
- }
-
var obj *provisioning.ResourceWrapper
code := http.StatusOK
switch r.Method {
diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local.go
index fc7bd18c99f..8c1ca1d56c0 100644
--- a/pkg/registry/apis/provisioning/repository/local.go
+++ b/pkg/registry/apis/provisioning/repository/local.go
@@ -360,5 +360,12 @@ func (r *localRepository) Delete(ctx context.Context, path string, ref string, c
return err
}
- return os.Remove(safepath.Join(r.path, path))
+ fullPath := safepath.Join(r.path, path)
+
+ if safepath.IsDir(path) {
+ // if it is a folder, delete all of its contents
+ return os.RemoveAll(fullPath)
+ }
+
+ return os.Remove(fullPath)
}
diff --git a/pkg/registry/apis/provisioning/repository/local_test.go b/pkg/registry/apis/provisioning/repository/local_test.go
index 7aabc542402..8881ab3cb6f 100644
--- a/pkg/registry/apis/provisioning/repository/local_test.go
+++ b/pkg/registry/apis/provisioning/repository/local_test.go
@@ -542,6 +542,41 @@ func TestLocalRepository_Delete(t *testing.T) {
comment: "test delete with ref",
expectedErr: apierrors.NewBadRequest("local repository does not support ref"),
},
+ {
+ name: "delete folder with nested files",
+ setup: func(t *testing.T) (string, *localRepository) {
+ tempDir := t.TempDir()
+ nestedFolderPath := filepath.Join(tempDir, "folder")
+ err := os.MkdirAll(nestedFolderPath, 0700)
+ require.NoError(t, err)
+ subFolderPath := filepath.Join(nestedFolderPath, "nested-folder")
+ err = os.MkdirAll(subFolderPath, 0700)
+ require.NoError(t, err)
+ err = os.WriteFile(filepath.Join(nestedFolderPath, "nested-dash.txt"), []byte("content1"), 0600)
+ require.NoError(t, err)
+
+ // Create repository with the temp directory as permitted prefix
+ repo := &localRepository{
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Local: &provisioning.LocalRepositoryConfig{
+ Path: tempDir,
+ },
+ },
+ },
+ resolver: &LocalFolderResolver{
+ PermittedPrefixes: []string{tempDir},
+ },
+ path: tempDir,
+ }
+
+ return tempDir, repo
+ },
+ path: "folder/",
+ ref: "",
+ comment: "test delete folder with nested content",
+ expectedErr: nil,
+ },
}
for _, tc := range testCases {
diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go
index 7d7b46e0406..d35c66b4f16 100644
--- a/pkg/registry/apis/provisioning/resources/dualwriter.go
+++ b/pkg/registry/apis/provisioning/resources/dualwriter.go
@@ -3,9 +3,12 @@ package resources
import (
"context"
"fmt"
+ "sort"
+ "strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
@@ -76,9 +79,8 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, err
}
- // TODO: implement this
if safepath.IsDir(opts.Path) {
- return nil, fmt.Errorf("folder delete not supported")
+ return r.deleteFolder(ctx, opts)
}
// Read the file from the default branch as it won't exist in the possibly new branch
@@ -131,6 +133,24 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return parsed, err
}
+func (r *DualReadWriter) getConfiguredBranch() string {
+ cfg := r.repo.Config()
+ switch cfg.Spec.Type {
+ case provisioning.GitHubRepositoryType:
+ if cfg.Spec.GitHub != nil {
+ return cfg.Spec.GitHub.Branch
+ }
+ case provisioning.GitRepositoryType:
+ if cfg.Spec.Git != nil {
+ return cfg.Spec.Git.Branch
+ }
+ case provisioning.LocalRepositoryType:
+ // branches are not supported for local repositories
+ return ""
+ }
+ return ""
+}
+
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
@@ -317,3 +337,126 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) er
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
fmt.Errorf("must be admin or editor to access folders with provisioning"))
}
+
+func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
+ // if the ref is not the active branch, just delete the files from the branch
+ // do not delete the items from grafana itself
+ if opts.Ref != "" && opts.Ref != r.getConfiguredBranch() {
+ err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
+ if err != nil {
+ return nil, fmt.Errorf("error deleting folder from repository: %w", err)
+ }
+
+ return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
+ }
+
+ // before deleting from the repo, first get all children resources to delete from grafana afterwards
+ treeEntries, err := r.repo.ReadTree(ctx, "")
+ if err != nil {
+ return nil, fmt.Errorf("read repository tree: %w", err)
+ }
+ // note: parsedFolders will include the folder itself
+ parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries)
+ if err != nil {
+ return nil, fmt.Errorf("parse resources in folder: %w", err)
+ }
+
+ // delete from the repo
+ err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
+ if err != nil {
+ return nil, fmt.Errorf("delete folder from repository: %w", err)
+ }
+
+ // delete from grafana
+ ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
+ if err != nil {
+ return nil, err
+ }
+ if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil {
+ return nil, fmt.Errorf("delete folder from grafana: %w", err)
+ }
+
+ return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
+}
+
+func folderDeleteResponse(path, ref string, cfg *provisioning.Repository) *ParsedResource {
+ return &ParsedResource{
+ Action: provisioning.ResourceActionDelete,
+ Info: &repository.FileInfo{
+ Path: path,
+ Ref: ref,
+ },
+ GVK: schema.GroupVersionKind{
+ Group: FolderResource.Group,
+ Version: FolderResource.Version,
+ Kind: "Folder",
+ },
+ GVR: FolderResource,
+ Repo: provisioning.ResourceRepositoryInfo{
+ Type: cfg.Spec.Type,
+ Namespace: cfg.Namespace,
+ Name: cfg.Name,
+ Title: cfg.Spec.Title,
+ },
+ }
+}
+
+func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
+ var resourcesInFolder []repository.FileTreeEntry
+ var foldersInFolder []Folder
+ for _, entry := range treeEntries {
+ // the folder itself should be included in this, to do that, trim the suffix of the folder path and see if it matches exactly
+ if !strings.HasPrefix(entry.Path, folderPath) && entry.Path != strings.TrimSuffix(folderPath, "/") {
+ continue
+ }
+ // folders cannot be parsed as resources, so handle them separately
+ if entry.Blob {
+ resourcesInFolder = append(resourcesInFolder, entry)
+ } else {
+ folder := ParseFolder(entry.Path, r.repo.Config().Name)
+ foldersInFolder = append(foldersInFolder, folder)
+ }
+ }
+
+ var parsedResources []*ParsedResource
+ for _, entry := range resourcesInFolder {
+ fileInfo, err := r.repo.Read(ctx, entry.Path, "")
+ if err != nil && !apierrors.IsNotFound(err) {
+ return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
+ }
+
+ parsed, err := r.parser.Parse(ctx, fileInfo)
+ if err != nil {
+ return nil, nil, fmt.Errorf("could not parse resource: %w", err)
+ }
+
+ parsedResources = append(parsedResources, parsed)
+ }
+
+ return parsedResources, foldersInFolder, nil
+}
+
+func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error {
+ for _, parsed := range childrenResources {
+ err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
+ if err != nil && !apierrors.IsNotFound(err) {
+ return fmt.Errorf("failed to delete nested resource from grafana: %w", err)
+ }
+ }
+
+ // we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
+ sort.Slice(folders, func(i, j int) bool {
+ depthI := strings.Count(folders[i].Path, "/")
+ depthJ := strings.Count(folders[j].Path, "/")
+
+ return depthI > depthJ
+ })
+ for _, f := range folders {
+ err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
+ if err != nil {
+ return fmt.Errorf("failed to delete folder from grafana: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go
index f9a4be946fc..b00501bdd2b 100644
--- a/pkg/tests/apis/provisioning/provisioning_test.go
+++ b/pkg/tests/apis/provisioning/provisioning_test.go
@@ -3,6 +3,7 @@ package provisioning
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"os"
"path/filepath"
@@ -651,3 +652,126 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) {
require.Nil(t, obj["status"], "should not have a status element")
}
}
+
+func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ helper := runGrafana(t)
+ ctx := context.Background()
+
+ const repo = "delete-test-repo"
+ localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
+ "Name": repo,
+ "SyncEnabled": true,
+ "SyncTarget": "instance",
+ })
+ _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
+ require.NoError(t, err)
+
+ // create the structure:
+ // dashboard1.json
+ // folder/
+ // dashboard2.json
+ // nested/
+ // dashboard3.json
+ dashboard1 := helper.LoadFile("testdata/all-panels.json")
+ result := helper.AdminREST.Post().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ SubResource("files", "dashboard1.json").
+ Body(dashboard1).
+ SetHeader("Content-Type", "application/json").
+ Do(ctx)
+ require.NoError(t, result.Error())
+ dashboard2 := helper.LoadFile("testdata/text-options.json")
+ result = helper.AdminREST.Post().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ SubResource("files", "folder", "dashboard2.json").
+ Body(dashboard2).
+ SetHeader("Content-Type", "application/json").
+ Do(ctx)
+ require.NoError(t, result.Error())
+ dashboard3 := helper.LoadFile("testdata/timeline-demo.json")
+ result = helper.AdminREST.Post().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ SubResource("files", "folder", "nested", "dashboard3.json").
+ Body(dashboard3).
+ SetHeader("Content-Type", "application/json").
+ Do(ctx)
+ require.NoError(t, result.Error())
+
+ helper.SyncAndWait(t, repo, nil)
+
+ dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 3, len(dashboards.Items))
+
+ folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 2, len(folders.Items))
+
+ t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) {
+ result := helper.AdminREST.Delete().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ SubResource("files", "dashboard1.json").
+ Do(ctx)
+ require.NoError(t, result.Error())
+ _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json")
+ require.Error(t, err)
+ dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 2, len(dashboards.Items))
+ })
+
+ t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) {
+ // need to delete directly through the url, because the k8s client doesn't support `/` in a subresource
+ // but that is needed by gitsync to know that it is a folder
+ 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/", 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.StatusOK, resp.StatusCode)
+
+ // should be deleted from the repo
+ _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder")
+ require.Error(t, err)
+ _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json")
+ require.Error(t, err)
+ _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested")
+ require.Error(t, err)
+ _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json")
+ require.Error(t, err)
+
+ // all should be deleted from grafana
+ for _, d := range dashboards.Items {
+ _, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{})
+ require.Error(t, err)
+ }
+ for _, f := range folders.Items {
+ _, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{})
+ require.Error(t, err)
+ }
+ })
+
+ t.Run("deleting a non-existent file should fail", func(t *testing.T) {
+ result := helper.AdminREST.Delete().
+ Namespace("default").
+ Resource("repositories").
+ Name(repo).
+ SubResource("files", "non-existent.json").
+ Do(ctx)
+ require.Error(t, result.Error())
+ })
+}
diff --git a/pkg/tests/apis/provisioning/testdata/timeline-demo.json b/pkg/tests/apis/provisioning/testdata/timeline-demo.json
new file mode 120000
index 00000000000..af7d0ce7aac
--- /dev/null
+++ b/pkg/tests/apis/provisioning/testdata/timeline-demo.json
@@ -0,0 +1 @@
+../../../../../devenv/dev-dashboards/panel-timeline/timeline-demo.json
\ No newline at end of file
From 4386085aa9a1f7c3c2b723835ea63815a66169c5 Mon Sep 17 00:00:00 2001
From: Stephanie Hingtgen
Date: Mon, 30 Jun 2025 18:11:27 -0600
Subject: [PATCH 12/33] fix linter
---
pkg/registry/apis/provisioning/resources/dualwriter.go | 6 +++---
pkg/tests/apis/provisioning/provisioning_test.go | 3 +--
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go
index d35c66b4f16..3a9f5a05570 100644
--- a/pkg/registry/apis/provisioning/resources/dualwriter.go
+++ b/pkg/registry/apis/provisioning/resources/dualwriter.go
@@ -418,8 +418,8 @@ func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, tre
}
}
- var parsedResources []*ParsedResource
- for _, entry := range resourcesInFolder {
+ parsedResources := make([]*ParsedResource, len(resourcesInFolder))
+ for i, entry := range resourcesInFolder {
fileInfo, err := r.repo.Read(ctx, entry.Path, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
@@ -430,7 +430,7 @@ func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, tre
return nil, nil, fmt.Errorf("could not parse resource: %w", err)
}
- parsedResources = append(parsedResources, parsed)
+ parsedResources[i] = parsed
}
return parsedResources, foldersInFolder, nil
diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go
index b00501bdd2b..db5eeb2ce2b 100644
--- a/pkg/tests/apis/provisioning/provisioning_test.go
+++ b/pkg/tests/apis/provisioning/provisioning_test.go
@@ -707,8 +707,6 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
Do(ctx)
require.NoError(t, result.Error())
- helper.SyncAndWait(t, repo, nil)
-
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, 3, len(dashboards.Items))
@@ -741,6 +739,7 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
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)
From 2484402f7abf79b77de9587effca7eff3c552679 Mon Sep 17 00:00:00 2001
From: Stephanie Hingtgen
Date: Wed, 2 Jul 2025 22:09:40 -0600
Subject: [PATCH 13/33] address PR comments
---
.../apis/provisioning/jobs/sync/changes.go | 13 +---
.../apis/provisioning/repository/local.go | 4 +-
.../provisioning/repository/local_test.go | 12 +--
.../apis/provisioning/resources/dualwriter.go | 77 ++++++++++---------
.../apis/provisioning/resources/tree.go | 7 +-
.../apis/provisioning/safepath/walk.go | 20 +++++
.../apis/provisioning/safepath/walk_test.go | 53 +++++++++++++
.../apis/provisioning/provisioning_test.go | 3 +
pkg/tests/apis/provisioning/testdata/.keep | 1 +
9 files changed, 128 insertions(+), 62 deletions(-)
create mode 100644 pkg/tests/apis/provisioning/testdata/.keep
diff --git a/pkg/registry/apis/provisioning/jobs/sync/changes.go b/pkg/registry/apis/provisioning/jobs/sync/changes.go
index 2e9b789c992..4c1f5db9136 100644
--- a/pkg/registry/apis/provisioning/jobs/sync/changes.go
+++ b/pkg/registry/apis/provisioning/jobs/sync/changes.go
@@ -3,7 +3,6 @@ package sync
import (
"context"
"fmt"
- "sort"
"strings"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
@@ -143,17 +142,7 @@ func Changes(source []repository.FileTreeEntry, target *provisioning.ResourceLis
}
// Deepest first (stable sort order)
- sort.Slice(changes, func(i, j int) bool {
- if safepath.Depth(changes[i].Path) > safepath.Depth(changes[j].Path) {
- return true
- }
-
- if safepath.Depth(changes[i].Path) < safepath.Depth(changes[j].Path) {
- return false
- }
-
- return changes[i].Path < changes[j].Path
- })
+ safepath.SortByDepth(changes, func(c ResourceFileChange) string { return c.Path }, false)
return changes, nil
}
diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local.go
index 8c1ca1d56c0..202bb295a5d 100644
--- a/pkg/registry/apis/provisioning/repository/local.go
+++ b/pkg/registry/apis/provisioning/repository/local.go
@@ -251,8 +251,10 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
if err != nil {
return fmt.Errorf("read and calculate hash of path %s: %w", path, err)
}
+ } else if !strings.HasSuffix(entry.Path, "/") {
+ // ensure trailing slash for directories
+ entry.Path = entry.Path + "/"
}
- // TODO: do folders have a trailing slash?
entries = append(entries, entry)
return err
})
diff --git a/pkg/registry/apis/provisioning/repository/local_test.go b/pkg/registry/apis/provisioning/repository/local_test.go
index 8881ab3cb6f..5f4e9985c9b 100644
--- a/pkg/registry/apis/provisioning/repository/local_test.go
+++ b/pkg/registry/apis/provisioning/repository/local_test.go
@@ -91,14 +91,14 @@ func TestLocalResolver(t *testing.T) {
// Verify all directories and files are present
expectedPaths := []string{
- "another",
- "another/path",
+ "another/",
+ "another/path/",
"another/path/file.txt",
- "level1",
+ "level1/",
"level1/file1.txt",
- "level1/level2",
+ "level1/level2/",
"level1/level2/file2.txt",
- "level1/level2/level3",
+ "level1/level2/level3/",
"level1/level2/level3/file3.txt",
"root.txt",
}
@@ -1382,7 +1382,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
expected: []FileTreeEntry{
{Path: "file1.txt", Blob: true, Size: 8},
{Path: "file2.txt", Blob: true, Size: 8},
- {Path: "subdir", Blob: false},
+ {Path: "subdir/", Blob: false},
{Path: "subdir/file3.txt", Blob: true, Size: 8},
},
expectedErr: nil,
diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go
index 3a9f5a05570..2145285d52e 100644
--- a/pkg/registry/apis/provisioning/resources/dualwriter.go
+++ b/pkg/registry/apis/provisioning/resources/dualwriter.go
@@ -3,8 +3,6 @@ package resources
import (
"context"
"fmt"
- "sort"
- "strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -133,24 +131,6 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return parsed, err
}
-func (r *DualReadWriter) getConfiguredBranch() string {
- cfg := r.repo.Config()
- switch cfg.Spec.Type {
- case provisioning.GitHubRepositoryType:
- if cfg.Spec.GitHub != nil {
- return cfg.Spec.GitHub.Branch
- }
- case provisioning.GitRepositoryType:
- if cfg.Spec.Git != nil {
- return cfg.Spec.Git.Branch
- }
- case provisioning.LocalRepositoryType:
- // branches are not supported for local repositories
- return ""
- }
- return ""
-}
-
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
@@ -186,6 +166,12 @@ func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions
},
}
+ urls, err := getFolderURLs(ctx, opts.Path, opts.Ref, r.repo)
+ if err != nil {
+ return nil, err
+ }
+ wrap.URLs = urls
+
if opts.Ref == "" {
folderName, err := r.folders.EnsureFolderPathExist(ctx, opts.Path)
if err != nil {
@@ -339,15 +325,15 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) er
}
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
- // if the ref is not the active branch, just delete the files from the branch
- // do not delete the items from grafana itself
- if opts.Ref != "" && opts.Ref != r.getConfiguredBranch() {
+ // if the ref is set, it is not the active branch, so just delete the files from the branch
+ // and do not delete the items from grafana itself
+ if opts.Ref != "" {
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
}
- return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
+ return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
// before deleting from the repo, first get all children resources to delete from grafana afterwards
@@ -376,11 +362,27 @@ func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions
return nil, fmt.Errorf("delete folder from grafana: %w", err)
}
- return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
+ return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo)
}
-func folderDeleteResponse(path, ref string, cfg *provisioning.Repository) *ParsedResource {
- return &ParsedResource{
+func getFolderURLs(ctx context.Context, path, ref string, repo repository.Repository) (*provisioning.ResourceURLs, error) {
+ if urlRepo, ok := repo.(repository.RepositoryWithURLs); ok && ref != "" {
+ urls, err := urlRepo.ResourceURLs(ctx, &repository.FileInfo{Path: path, Ref: ref})
+ if err != nil {
+ return nil, err
+ }
+ return urls, nil
+ }
+ return nil, nil
+}
+
+func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
+ urls, err := getFolderURLs(ctx, path, ref, repo)
+ if err != nil {
+ return nil, err
+ }
+
+ parsed := &ParsedResource{
Action: provisioning.ResourceActionDelete,
Info: &repository.FileInfo{
Path: path,
@@ -393,20 +395,23 @@ func folderDeleteResponse(path, ref string, cfg *provisioning.Repository) *Parse
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
- Type: cfg.Spec.Type,
- Namespace: cfg.Namespace,
- Name: cfg.Name,
- Title: cfg.Spec.Title,
+ Type: repo.Config().Spec.Type,
+ Namespace: repo.Config().Namespace,
+ Name: repo.Config().Name,
+ Title: repo.Config().Spec.Title,
},
+ URLs: urls,
}
+
+ return parsed, nil
}
func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
var resourcesInFolder []repository.FileTreeEntry
var foldersInFolder []Folder
for _, entry := range treeEntries {
- // the folder itself should be included in this, to do that, trim the suffix of the folder path and see if it matches exactly
- if !strings.HasPrefix(entry.Path, folderPath) && entry.Path != strings.TrimSuffix(folderPath, "/") {
+ // make sure the path is supported (i.e. not ignored by git sync) and that the path is the folder itself or a child of the folder
+ if IsPathSupported(entry.Path) != nil || !safepath.InDir(entry.Path, folderPath) {
continue
}
// folders cannot be parsed as resources, so handle them separately
@@ -445,12 +450,8 @@ func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources [
}
// we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
- sort.Slice(folders, func(i, j int) bool {
- depthI := strings.Count(folders[i].Path, "/")
- depthJ := strings.Count(folders[j].Path, "/")
+ safepath.SortByDepth(folders, func(f Folder) string { return f.Path }, false)
- return depthI > depthJ
- })
for _, f := range folders {
err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
if err != nil {
diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go
index e18ad25f873..427384e8753 100644
--- a/pkg/registry/apis/provisioning/resources/tree.go
+++ b/pkg/registry/apis/provisioning/resources/tree.go
@@ -3,7 +3,6 @@ package resources
import (
"context"
"fmt"
- "sort"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -95,10 +94,8 @@ func (t *folderTree) Walk(ctx context.Context, fn WalkFunc) error {
toWalk = append(toWalk, folder)
}
- // sort by depth of the paths
- sort.Slice(toWalk, func(i, j int) bool {
- return safepath.Depth(toWalk[i].Path) < safepath.Depth(toWalk[j].Path)
- })
+ // sort by depth (shallowest first)
+ safepath.SortByDepth(toWalk, func(f Folder) string { return f.Path }, true)
for _, folder := range toWalk {
if err := fn(ctx, folder, t.tree[folder.ID]); err != nil {
diff --git a/pkg/registry/apis/provisioning/safepath/walk.go b/pkg/registry/apis/provisioning/safepath/walk.go
index 048745da188..a048d239e2d 100644
--- a/pkg/registry/apis/provisioning/safepath/walk.go
+++ b/pkg/registry/apis/provisioning/safepath/walk.go
@@ -3,6 +3,7 @@ package safepath
import (
"context"
"path"
+ "sort"
"strings"
)
@@ -43,3 +44,22 @@ func Split(p string) []string {
}
return strings.Split(trimmed, "/")
}
+
+// SortByDepth will sort any resource, by its path depth. You must pass in
+// a way to get said path. Ties are alphabetical by default.
+func SortByDepth[T any](items []T, pathExtractor func(T) string, asc bool) {
+ sort.Slice(items, func(i, j int) bool {
+ pathI, pathJ := pathExtractor(items[i]), pathExtractor(items[j])
+ depthI, depthJ := Depth(pathI), Depth(pathJ)
+
+ if depthI == depthJ {
+ // alphabetical by default if depth is the same
+ return pathI < pathJ
+ }
+
+ if asc {
+ return depthI < depthJ
+ }
+ return depthI > depthJ
+ })
+}
diff --git a/pkg/registry/apis/provisioning/safepath/walk_test.go b/pkg/registry/apis/provisioning/safepath/walk_test.go
index c1694f2af53..9789e8c0ab0 100644
--- a/pkg/registry/apis/provisioning/safepath/walk_test.go
+++ b/pkg/registry/apis/provisioning/safepath/walk_test.go
@@ -176,3 +176,56 @@ func TestWalkError(t *testing.T) {
require.ErrorIs(t, err, expectedErr)
}
+
+func TestSortByDepth(t *testing.T) {
+ tests := []struct {
+ name string
+ asc bool
+ paths []string
+ expected []string
+ }{
+ {
+ name: "ascending sort (shallowest first)",
+ paths: []string{"a/b/c", "a", "a/b", "d/e/f/g"},
+ asc: true,
+ expected: []string{"a", "a/b", "a/b/c", "d/e/f/g"},
+ },
+ {
+ name: "descending sort with alphabetical tie-break",
+ paths: []string{"a/b/c", "a", "a/b", "d/e/f/g", "x/y/z"},
+ asc: false,
+ expected: []string{"d/e/f/g", "a/b/c", "x/y/z", "a/b", "a"},
+ },
+ {
+ name: "paths with empty string",
+ paths: []string{"a/b/c", "", "a", "a/b"},
+ asc: true,
+ expected: []string{"", "a", "a/b", "a/b/c"},
+ },
+ {
+ name: "paths with trailing slashes",
+ paths: []string{"a/b/", "a/b/c", "b/", "a/", "a"},
+ asc: true,
+ expected: []string{"a", "a/", "b/", "a/b/", "a/b/c"},
+ },
+ {
+ name: "single path",
+ paths: []string{"a/b/c"},
+ expected: []string{"a/b/c"},
+ },
+ {
+ name: "empty paths",
+ paths: []string{},
+ expected: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ paths := make([]string, len(tt.paths))
+ copy(paths, tt.paths)
+ SortByDepth(paths, func(s string) string { return s }, tt.asc)
+ assert.Equal(t, tt.expected, paths)
+ })
+ }
+}
diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go
index db5eeb2ce2b..a4a1a6dde11 100644
--- a/pkg/tests/apis/provisioning/provisioning_test.go
+++ b/pkg/tests/apis/provisioning/provisioning_test.go
@@ -707,6 +707,9 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
Do(ctx)
require.NoError(t, result.Error())
+ // make sure we don't fail when there is a .keep file in a folder
+ helper.CopyToProvisioningPath(t, "testdata/.keep", "folder/nested/.keep")
+
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, 3, len(dashboards.Items))
diff --git a/pkg/tests/apis/provisioning/testdata/.keep b/pkg/tests/apis/provisioning/testdata/.keep
new file mode 100644
index 00000000000..633f69fd54e
--- /dev/null
+++ b/pkg/tests/apis/provisioning/testdata/.keep
@@ -0,0 +1 @@
+# This file ensures the folder/nested directory is tracked in version control
\ No newline at end of file
From b0df15c7708c33150ebe02176e0e89dbe97f5e26 Mon Sep 17 00:00:00 2001
From: Roberto Jimenez Sanchez
Date: Mon, 7 Jul 2025 10:06:04 +0200
Subject: [PATCH 14/33] Fix issue with path in folder deletion
Folder path must not have a trailing slash in Nanogit
---
.../provisioning/repository/nanogit/git.go | 4 +-
.../repository/nanogit/git_test.go | 44 +++++++++++--------
2 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git.go b/pkg/registry/apis/provisioning/repository/nanogit/git.go
index c533d1fb85a..4ee0f0c6fa5 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/git.go
+++ b/pkg/registry/apis/provisioning/repository/nanogit/git.go
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/url"
+ "strings"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -450,7 +451,8 @@ func (r *gitRepository) delete(ctx context.Context, path string, writer nanogit.
finalPath := safepath.Join(r.gitConfig.Path, path)
// Check if it's a directory - use DeleteTree for directories, DeleteBlob for files
if safepath.IsDir(path) {
- if _, err := writer.DeleteTree(ctx, finalPath); err != nil {
+ trimmed := strings.TrimSuffix(finalPath, "/")
+ if _, err := writer.DeleteTree(ctx, trimmed); err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return repository.ErrFileNotFound
}
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git_test.go b/pkg/registry/apis/provisioning/repository/nanogit/git_test.go
index d9413c429f0..cfc4abdea06 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/git_test.go
+++ b/pkg/registry/apis/provisioning/repository/nanogit/git_test.go
@@ -1073,27 +1073,27 @@ func TestGitRepository_Update(t *testing.T) {
func TestGitRepository_Delete(t *testing.T) {
tests := []struct {
- name string
- setupMock func(*mocks.FakeClient)
- gitConfig RepositoryConfig
- path string
- ref string
- comment string
- wantError bool
- errorType error
+ name string
+ setupMock func(*mocks.FakeClient, *mocks.FakeStagedWriter)
+ assertions func(*testing.T, *mocks.FakeClient, *mocks.FakeStagedWriter)
+ gitConfig RepositoryConfig
+ path string
+ ref string
+ comment string
+ wantError bool
+ errorType error
}{
{
name: "success - delete file",
- setupMock: func(mockClient *mocks.FakeClient) {
+ setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
- mockWriter := &mocks.FakeStagedWriter{}
+
mockWriter.DeleteBlobReturns(hash.Hash{}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
- mockClient.NewStagedWriterReturns(mockWriter, nil)
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1106,16 +1106,19 @@ func TestGitRepository_Delete(t *testing.T) {
},
{
name: "success - delete directory",
- setupMock: func(mockClient *mocks.FakeClient) {
+ setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
- mockWriter := &mocks.FakeStagedWriter{}
mockWriter.DeleteTreeReturns(hash.Hash{}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
- mockClient.NewStagedWriterReturns(mockWriter, nil)
+ },
+ assertions: func(t *testing.T, fakeClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
+ require.Equal(t, 1, mockWriter.DeleteTreeCallCount(), "DeleteTree should be called once")
+ _, p := mockWriter.DeleteTreeArgsForCall(0)
+ require.Equal(t, "configs/testdir", p, "DeleteTree should be called with correct path")
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1128,14 +1131,12 @@ func TestGitRepository_Delete(t *testing.T) {
},
{
name: "failure - file not found",
- setupMock: func(mockClient *mocks.FakeClient) {
+ setupMock: func(mockClient *mocks.FakeClient, mockWriter *mocks.FakeStagedWriter) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/main",
Hash: hash.Hash{},
}, nil)
- mockWriter := &mocks.FakeStagedWriter{}
mockWriter.DeleteBlobReturns(hash.Hash{}, nanogit.ErrObjectNotFound)
- mockClient.NewStagedWriterReturns(mockWriter, nil)
},
gitConfig: RepositoryConfig{
Branch: "main",
@@ -1152,7 +1153,10 @@ func TestGitRepository_Delete(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mocks.FakeClient{}
- tt.setupMock(mockClient)
+ mockWriter := &mocks.FakeStagedWriter{}
+ mockClient.NewStagedWriterReturns(mockWriter, nil)
+
+ tt.setupMock(mockClient, mockWriter)
gitRepo := &gitRepository{
client: mockClient,
@@ -1174,6 +1178,10 @@ func TestGitRepository_Delete(t *testing.T) {
} else {
require.NoError(t, err)
}
+
+ if tt.assertions != nil {
+ tt.assertions(t, mockClient, mockWriter)
+ }
})
}
}
From 106206ae936e9ec64e575abfffeaf240a9b66abb Mon Sep 17 00:00:00 2001
From: Roberto Jimenez Sanchez
Date: Mon, 7 Jul 2025 10:16:26 +0200
Subject: [PATCH 15/33] Ignore delete error if not found
---
pkg/registry/apis/provisioning/resources/resources.go | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go
index 0acbbc4c36a..13e9d861113 100644
--- a/pkg/registry/apis/provisioning/resources/resources.go
+++ b/pkg/registry/apis/provisioning/resources/resources.go
@@ -7,6 +7,7 @@ import (
"fmt"
"slices"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -206,6 +207,10 @@ func (r *ResourcesManager) RemoveResourceFromFile(ctx context.Context, path stri
err = client.Delete(ctx, objName, metav1.DeleteOptions{})
if err != nil {
+ if apierrors.IsNotFound(err) {
+ return objName, schema.GroupVersionKind{}, nil // Already deleted or simply non-existing, nothing to do
+ }
+
return "", schema.GroupVersionKind{}, fmt.Errorf("failed to delete: %w", err)
}
From 956ae0b28395b909e2428b70d3869ba99f4e882f Mon Sep 17 00:00:00 2001
From: Mariell Hoversholm
Date: Thu, 10 Jul 2025 12:58:13 +0200
Subject: [PATCH 16/33] Actions: Run prettier on docs changes (#107949)
---
.github/workflows/frontend-lint.yml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml
index e5ceb117eea..fb0362bb580 100644
--- a/.github/workflows/frontend-lint.yml
+++ b/.github/workflows/frontend-lint.yml
@@ -16,6 +16,7 @@ jobs:
contents: read
outputs:
changed: ${{ steps.detect-changes.outputs.frontend }}
+ prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }}
steps:
- uses: actions/checkout@v4
with:
@@ -34,7 +35,7 @@ jobs:
id-token: write
# Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`,
# the `lint-frontend-prettier-enterprise` workflow will run instead
- if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true'
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.prettier == 'true'
name: Lint
runs-on: ubuntu-latest
steps:
@@ -55,7 +56,7 @@ jobs:
contents: read
id-token: write
# Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks)
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true'
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.prettier == 'true'
name: Lint
runs-on: ubuntu-latest
steps:
From 5ec1bd91df8bb02f6bf835596c57a4d4fcbf3c1d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=A1bor=20Farkas?=
Date: Thu, 10 Jul 2025 13:28:45 +0200
Subject: [PATCH 17/33] datasources: querier: log empty refids (#107111)
* datasources: querier: log empty refids
* improved logging
---
pkg/registry/apis/query/query.go | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go
index edc3be07ac4..237ad94b085 100644
--- a/pkg/registry/apis/query/query.go
+++ b/pkg/registry/apis/query/query.go
@@ -179,6 +179,8 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
return
}
+ logEmptyRefids(raw.Queries, b.log)
+
for i := range req.Requests {
req.Requests[i].Headers = ExtractKnownHeaders(httpreq.Header)
}
@@ -215,6 +217,20 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O
}), nil
}
+func logEmptyRefids(queries []v0alpha1.DataQuery, logger log.Logger) {
+ emptyCount := 0
+
+ for _, q := range queries {
+ if q.RefID == "" {
+ emptyCount += 1
+ }
+ }
+
+ if emptyCount > 0 {
+ logger.Info("empty refid found", "empty_count", emptyCount, "query_count", len(queries))
+ }
+}
+
func (b *QueryAPIBuilder) execute(ctx context.Context, req parsedRequestInfo, instanceConfig clientapi.InstanceConfigurationSettings) (qdr *backend.QueryDataResponse, err error) {
switch len(req.Requests) {
case 0:
From c788b35dae1e9135f05181cb328f11860834136f Mon Sep 17 00:00:00 2001
From: John-George Sample
Date: Thu, 10 Jul 2025 07:32:36 -0400
Subject: [PATCH 18/33] chore: fix typo in FlameGraph docs (#107921)
---
packages/grafana-flamegraph/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/grafana-flamegraph/README.md b/packages/grafana-flamegraph/README.md
index 9bd09dfc6c6..5022f4b2199 100644
--- a/packages/grafana-flamegraph/README.md
+++ b/packages/grafana-flamegraph/README.md
@@ -9,7 +9,7 @@ This is a Flamegraph component that is used in Grafana and Pyroscope web app to
Currently this library exposes single component `Flamegraph` that renders whole visualization used for profiling which contains a header, a table representation of the data and a flamegraph.
```tsx
-import { Flamegraph } from '@grafana/flamegraph';
+import { FlameGraph } from '@grafana/flamegraph';
createTheme({ colors: { mode: 'dark' } })}
From 325863ba94f5d001deb22744e724b02d451d3eed Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 12:47:02 +0100
Subject: [PATCH 19/33] Update faro to v1.19.0 (#107946)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
yarn.lock | 136 ++++++++++++++++++++++++------------------------------
1 file changed, 60 insertions(+), 76 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 5d851cf5fa0..ab588cc29c4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3169,43 +3169,43 @@ __metadata:
languageName: unknown
linkType: soft
-"@grafana/faro-core@npm:^1.13.2, @grafana/faro-core@npm:^1.18.2":
- version: 1.18.2
- resolution: "@grafana/faro-core@npm:1.18.2"
+"@grafana/faro-core@npm:^1.13.2, @grafana/faro-core@npm:^1.19.0":
+ version: 1.19.0
+ resolution: "@grafana/faro-core@npm:1.19.0"
dependencies:
"@opentelemetry/api": "npm:^1.9.0"
- "@opentelemetry/otlp-transformer": "npm:^0.201.0"
- checksum: 10/b211039b8b7381d2cb6bc8f80dc25d1c7ce484e426c6406bdcdd94c9e694b88387704b0408b1e11a0c5127dd9b1fc29544b24454b0e68f8458efb1a0166de657
+ "@opentelemetry/otlp-transformer": "npm:^0.202.0"
+ checksum: 10/9bb3549074e86dea152469c558a3f126e231c068059a605f5afdf7c1bca1efd6f634a0c1bbbf068de1c1bd841d9c46f92b20490a1dd5a623451a38e73fab16e1
languageName: node
linkType: hard
-"@grafana/faro-web-sdk@npm:^1.13.2, @grafana/faro-web-sdk@npm:^1.18.2":
- version: 1.18.2
- resolution: "@grafana/faro-web-sdk@npm:1.18.2"
+"@grafana/faro-web-sdk@npm:^1.13.2, @grafana/faro-web-sdk@npm:^1.19.0":
+ version: 1.19.0
+ resolution: "@grafana/faro-web-sdk@npm:1.19.0"
dependencies:
- "@grafana/faro-core": "npm:^1.18.2"
+ "@grafana/faro-core": "npm:^1.19.0"
ua-parser-js: "npm:^1.0.32"
web-vitals: "npm:^4.0.1"
- checksum: 10/f2b5d0f794c5d493c51947380081771436ed49dca02b7e1ea292f966da2e861514dc2d9ed74d1da821e85c398fb66b45c8eda2f6f628c7d6e4bfc8f498074c93
+ checksum: 10/a7a3441167e55e4e59d70867ffcb860f177c386a33c22c45b6bbc1aeba9280891becb435699dd06d3d53cf8823365626787a085910b93d27073f3c0264fff7bc
languageName: node
linkType: hard
"@grafana/faro-web-tracing@npm:^1.13.2":
- version: 1.18.2
- resolution: "@grafana/faro-web-tracing@npm:1.18.2"
+ version: 1.19.0
+ resolution: "@grafana/faro-web-tracing@npm:1.19.0"
dependencies:
- "@grafana/faro-web-sdk": "npm:^1.18.2"
+ "@grafana/faro-web-sdk": "npm:^1.19.0"
"@opentelemetry/api": "npm:^1.9.0"
"@opentelemetry/core": "npm:^2.0.0"
- "@opentelemetry/exporter-trace-otlp-http": "npm:^0.201.0"
- "@opentelemetry/instrumentation": "npm:^0.201.0"
- "@opentelemetry/instrumentation-fetch": "npm:^0.201.0"
- "@opentelemetry/instrumentation-xml-http-request": "npm:^0.201.0"
- "@opentelemetry/otlp-transformer": "npm:^0.201.0"
+ "@opentelemetry/exporter-trace-otlp-http": "npm:^0.202.0"
+ "@opentelemetry/instrumentation": "npm:^0.202.0"
+ "@opentelemetry/instrumentation-fetch": "npm:^0.202.0"
+ "@opentelemetry/instrumentation-xml-http-request": "npm:^0.202.0"
+ "@opentelemetry/otlp-transformer": "npm:^0.202.0"
"@opentelemetry/resources": "npm:^2.0.0"
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
"@opentelemetry/semantic-conventions": "npm:^1.32.0"
- checksum: 10/5ca81618b52216da3acac456d149f62d1514c9e11b9d7a091ae9fb56e99e6adf2fb5a8630db13473524fd9c818a392366a7142ba84334e8981e956a110186bcd
+ checksum: 10/da7e0cd6b3fb5269d26530bdb212b005698ea489748c090fe6867bdb501e124c69ea8157a88f6e6115472500c4358f86001c104ccb24b6c9b8ad24c3b525917f
languageName: node
linkType: hard
@@ -5657,12 +5657,12 @@ __metadata:
languageName: node
linkType: hard
-"@opentelemetry/api-logs@npm:0.201.1":
- version: 0.201.1
- resolution: "@opentelemetry/api-logs@npm:0.201.1"
+"@opentelemetry/api-logs@npm:0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/api-logs@npm:0.202.0"
dependencies:
"@opentelemetry/api": "npm:^1.3.0"
- checksum: 10/baa14906caf848b7ff32fdd2b8cbad5c96b6e5b4bb4e52cb4118b323b77b2e99630b4d58d92f110343d475a21fd5bdcaaa37c29a4a386136ed4ee01528a2b2ed
+ checksum: 10/22171137ad1d876a79a6f046b4adcc44a13941a07f2172948e4c8dbb6cacfe276b1fa5087f15e4acdd567f748010d531321c93ebf93a6c09586351c9048bf6c7
languageName: node
linkType: hard
@@ -5720,90 +5720,88 @@ __metadata:
languageName: node
linkType: hard
-"@opentelemetry/exporter-trace-otlp-http@npm:^0.201.0":
- version: 0.201.1
- resolution: "@opentelemetry/exporter-trace-otlp-http@npm:0.201.1"
+"@opentelemetry/exporter-trace-otlp-http@npm:^0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/exporter-trace-otlp-http@npm:0.202.0"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
- "@opentelemetry/otlp-exporter-base": "npm:0.201.1"
- "@opentelemetry/otlp-transformer": "npm:0.201.1"
+ "@opentelemetry/otlp-exporter-base": "npm:0.202.0"
+ "@opentelemetry/otlp-transformer": "npm:0.202.0"
"@opentelemetry/resources": "npm:2.0.1"
"@opentelemetry/sdk-trace-base": "npm:2.0.1"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/b1cec9287384900a3dc5326e3d2c089da9cb13b77f42660c201d0da2ae47f34dda81145ee14b1c8fcce12a59dd2511704d1179a0c991fdcf1c645bffdee6072a
+ checksum: 10/f9e60e51b5dcca3d4d32f2091f04faa2bcaf677b9f8a88990c8cdc7c6345e4244e46c4e8df1c336fc6f741aea092407032ade1236089eb729196e6fcdc055ad0
languageName: node
linkType: hard
-"@opentelemetry/instrumentation-fetch@npm:^0.201.0":
- version: 0.201.1
- resolution: "@opentelemetry/instrumentation-fetch@npm:0.201.1"
+"@opentelemetry/instrumentation-fetch@npm:^0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/instrumentation-fetch@npm:0.202.0"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
- "@opentelemetry/instrumentation": "npm:0.201.1"
+ "@opentelemetry/instrumentation": "npm:0.202.0"
"@opentelemetry/sdk-trace-web": "npm:2.0.1"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/9d096774fb93e1c690d40628669a383cb2c972a5aa699bd2fbeb4da7c30dd30e6959dd1b464e6c4d7b3f3d92c71364ca75b3ee5da8e291dc8939f9b9fcd1f1d7
+ checksum: 10/c3e87a75878b7b16f5c9ac66c73b6affb8d9d6e5ba95aa19c05a0f73c03d831e7d60e586d9a5bdf76e962acaadbe989b351fefe1908ab439afcd0158bded10e6
languageName: node
linkType: hard
-"@opentelemetry/instrumentation-xml-http-request@npm:^0.201.0":
- version: 0.201.1
- resolution: "@opentelemetry/instrumentation-xml-http-request@npm:0.201.1"
+"@opentelemetry/instrumentation-xml-http-request@npm:^0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/instrumentation-xml-http-request@npm:0.202.0"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
- "@opentelemetry/instrumentation": "npm:0.201.1"
+ "@opentelemetry/instrumentation": "npm:0.202.0"
"@opentelemetry/sdk-trace-web": "npm:2.0.1"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/5fec2a79a4b041a22e15a1aa06436b3881d06f25e3f5ee75309bab335ddadde4523045a6352e115b0331c48488cb878d0ebfc33d76cf34df5f73bcf6c07bcbca
+ checksum: 10/6a2c76374f5af2d37318f5a689447a99ecc65c7c5546f3421171c870d5ddf1536e30f6fd63d8046cbc3e4cbf737529bae304959d971beefaf7f730fefb9e663c
languageName: node
linkType: hard
-"@opentelemetry/instrumentation@npm:0.201.1, @opentelemetry/instrumentation@npm:^0.201.0":
- version: 0.201.1
- resolution: "@opentelemetry/instrumentation@npm:0.201.1"
+"@opentelemetry/instrumentation@npm:0.202.0, @opentelemetry/instrumentation@npm:^0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/instrumentation@npm:0.202.0"
dependencies:
- "@opentelemetry/api-logs": "npm:0.201.1"
- "@types/shimmer": "npm:^1.2.0"
+ "@opentelemetry/api-logs": "npm:0.202.0"
import-in-the-middle: "npm:^1.8.1"
require-in-the-middle: "npm:^7.1.1"
- shimmer: "npm:^1.2.1"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/cef0b05ce1b153f9d7ce770782c67f2422d98a6c1bd4bc5d69db6c2c3d91523c7030bb0ff6ff16ae246586334f4774c8d1b71bef5e395b7e7cba4645a73a14c4
+ checksum: 10/da1db1ebc4ca847cc68d894b2e3a6c6552851d93af8ea793d42474e920f710664575c8991dc269e1a83fcf8f5abdda2cc724fa24e9cc4ae19fa6f70eb68ffc0e
languageName: node
linkType: hard
-"@opentelemetry/otlp-exporter-base@npm:0.201.1":
- version: 0.201.1
- resolution: "@opentelemetry/otlp-exporter-base@npm:0.201.1"
+"@opentelemetry/otlp-exporter-base@npm:0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/otlp-exporter-base@npm:0.202.0"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
- "@opentelemetry/otlp-transformer": "npm:0.201.1"
+ "@opentelemetry/otlp-transformer": "npm:0.202.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/d9c64ebf531e5a7e3d42537d2058e331165e4764b4a54d453668c1f8bbfa14008255771110bb106aa44c094ad76933a46f37f67d4b362908d511e57e72b7cd09
+ checksum: 10/229778895ba1971451a8b1ec8a4787b0cdba337c87f3ca6aaf6d649f5e2b26439ba1befa36d31824fffa3cef62382792680ec70194f57aac6ebe98e95acecc69
languageName: node
linkType: hard
-"@opentelemetry/otlp-transformer@npm:0.201.1, @opentelemetry/otlp-transformer@npm:^0.201.0":
- version: 0.201.1
- resolution: "@opentelemetry/otlp-transformer@npm:0.201.1"
+"@opentelemetry/otlp-transformer@npm:0.202.0, @opentelemetry/otlp-transformer@npm:^0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/otlp-transformer@npm:0.202.0"
dependencies:
- "@opentelemetry/api-logs": "npm:0.201.1"
+ "@opentelemetry/api-logs": "npm:0.202.0"
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/resources": "npm:2.0.1"
- "@opentelemetry/sdk-logs": "npm:0.201.1"
+ "@opentelemetry/sdk-logs": "npm:0.202.0"
"@opentelemetry/sdk-metrics": "npm:2.0.1"
"@opentelemetry/sdk-trace-base": "npm:2.0.1"
protobufjs: "npm:^7.3.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
- checksum: 10/bed6f7d12aba212cfc9dd0c482de6d983f31a994faa4cb13f651f1cbe98ae8935ed25a4a25887cdcdc9a53af1ee8cd3406e869d900499c0cbadf87f3218dcdb4
+ checksum: 10/67e189af60bf8308a5b93deb85bef9709a5604b8c7915d0d42a7a2bff932b7257dfdfc071812c77913e136955a985ffe838c72b5f10059021087ea0bc52d84cd
languageName: node
linkType: hard
@@ -5831,16 +5829,16 @@ __metadata:
languageName: node
linkType: hard
-"@opentelemetry/sdk-logs@npm:0.201.1":
- version: 0.201.1
- resolution: "@opentelemetry/sdk-logs@npm:0.201.1"
+"@opentelemetry/sdk-logs@npm:0.202.0":
+ version: 0.202.0
+ resolution: "@opentelemetry/sdk-logs@npm:0.202.0"
dependencies:
- "@opentelemetry/api-logs": "npm:0.201.1"
+ "@opentelemetry/api-logs": "npm:0.202.0"
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/resources": "npm:2.0.1"
peerDependencies:
"@opentelemetry/api": ">=1.4.0 <1.10.0"
- checksum: 10/c2d8aad418268c5ab4ad18f8eea5bb11fff1659b9bbbcd30546a622c2a6e04e3361de7809e702bff7c151cf7c21408ab8fd798b43ffbc8f549bfb91d0c40d4bb
+ checksum: 10/e1b76647282a41ad7004c86c0058b0e9be70fdcf34aa2761929401e274af79bc5e3c6610a63e14fde4d51d96d8c96adaf3247c10b9a64cb70086d2ede2e421ca
languageName: node
linkType: hard
@@ -10075,13 +10073,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/shimmer@npm:^1.2.0":
- version: 1.2.0
- resolution: "@types/shimmer@npm:1.2.0"
- checksum: 10/f081a31d826ce7bfe8cc7ba8129d2b1dffae44fd580eba4fcf741237646c4c2494ae6de2cada4b7713d138f35f4bc512dbf01311d813dee82020f97d7d8c491c
- languageName: node
- linkType: hard
-
"@types/sinonjs__fake-timers@npm:8.1.1":
version: 8.1.1
resolution: "@types/sinonjs__fake-timers@npm:8.1.1"
@@ -28844,13 +28835,6 @@ __metadata:
languageName: node
linkType: hard
-"shimmer@npm:^1.2.1":
- version: 1.2.1
- resolution: "shimmer@npm:1.2.1"
- checksum: 10/aa0d6252ad1c682a4fdfda69e541be987f7a265ac7b00b1208e5e48cc68dc55f293955346ea4c71a169b7324b82c70f8400b3d3d2d60b2a7519f0a3522423250
- languageName: node
- linkType: hard
-
"short-unique-id@npm:^5.3.2":
version: 5.3.2
resolution: "short-unique-id@npm:5.3.2"
From 2e568ef672c64d258a1a5dc237b31d0e41164739 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?=
<32759850+mustafasencer@users.noreply.github.com>
Date: Thu, 10 Jul 2025 13:52:58 +0200
Subject: [PATCH 20/33] test: reenable dashboard integration tests and
restructure based on dual writer modes (#107941)
---
pkg/tests/apis/dashboard/dashboards_test.go | 164 ++++++--------------
1 file changed, 50 insertions(+), 114 deletions(-)
diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go
index c107eacb2c9..900b7ed7da2 100644
--- a/pkg/tests/apis/dashboard/dashboards_test.go
+++ b/pkg/tests/apis/dashboard/dashboards_test.go
@@ -2,6 +2,7 @@ package dashboards
import (
"context"
+ "fmt"
"strings"
"testing"
@@ -13,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
@@ -112,74 +114,28 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.Group
func TestIntegrationDashboardsAppV0Alpha1(t *testing.T) {
gvr := schema.GroupVersionResource{
- Group: dashboardV1.GROUP,
- Version: dashboardV1.VERSION,
+ Group: dashboardV0.GROUP,
+ Version: dashboardV0.VERSION,
Resource: "dashboards",
}
if testing.Short() {
t.Skip("skipping integration test")
}
- t.Run("v0alpha1 with dual writer mode 0", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 0,
+ modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
+ for _, mode := range modes {
+ t.Run(fmt.Sprintf("v0alpha1 with dual writer mode %d", mode), func(t *testing.T) {
+ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ DisableAnonymous: true,
+ UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
+ "dashboards.dashboard.grafana.app": {
+ DualWriterMode: mode,
+ },
},
- },
+ })
+ runDashboardTest(t, helper, gvr)
})
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v0alpha1 with dual writer mode 1", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 1,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v0alpha1 with dual writer mode 2", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 2,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v0alpha1 with dual writer mode 3", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 3,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v0alpha1 with dual writer mode 4", func(t *testing.T) {
- t.Skip("skipping test because of authorizer issue")
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 4,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
+ }
}
func TestIntegrationDashboardsAppV1(t *testing.T) {
@@ -192,66 +148,46 @@ func TestIntegrationDashboardsAppV1(t *testing.T) {
t.Skip("skipping integration test")
}
- t.Run("v1 with dual writer mode 0", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 0,
+ modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
+ for _, mode := range modes {
+ t.Run(fmt.Sprintf("v1beta1 with dual writer mode %d", mode), func(t *testing.T) {
+ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ DisableAnonymous: true,
+ UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
+ "dashboards.dashboard.grafana.app": {
+ DualWriterMode: mode,
+ },
},
- },
+ })
+ runDashboardTest(t, helper, gvr)
})
- runDashboardTest(t, helper, gvr)
- })
+ }
+}
- t.Run("v1 with dual writer mode 1", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 1,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
+func TestIntegrationDashboardsAppV2(t *testing.T) {
+ gvr := schema.GroupVersionResource{
+ Group: dashboardV2.GROUP,
+ Version: dashboardV2.VERSION,
+ Resource: "dashboards",
+ }
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
- t.Run("v1 with dual writer mode 2", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 2,
+ modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
+ for _, mode := range modes {
+ t.Run(fmt.Sprintf("v1beta1 with dual writer mode %d", mode), func(t *testing.T) {
+ helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ DisableAnonymous: true,
+ UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
+ "dashboards.dashboard.grafana.app": {
+ DualWriterMode: mode,
+ },
},
- },
+ })
+ runDashboardTest(t, helper, gvr)
})
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v1 with dual writer mode 3", func(t *testing.T) {
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 3,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
-
- t.Run("v1 with dual writer mode 4", func(t *testing.T) {
- t.Skip("skipping test because of authorizer issue")
- helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
- DisableAnonymous: true,
- UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
- "dashboards.dashboard.grafana.app": {
- DualWriterMode: 4,
- },
- },
- })
- runDashboardTest(t, helper, gvr)
- })
+ }
}
func TestIntegrationLegacySupport(t *testing.T) {
From 8fd5739576ea91c0b4685e5356b75ee22e00f34e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?=
Date: Thu, 10 Jul 2025 13:54:10 +0200
Subject: [PATCH 21/33] [unified-storage/search] Don't expire file-based
indexes, check for resource stats when building index on-demand (#107886)
* Get ResourceStats before indexing
* Replaced localcache.CacheService to handle expiration faster (localcache.CacheService / gocache.Cache only expires values at specific interval, but we need to close index faster)
* singleflight getOrBuildIndex for the same key
* expire only in-memory indexes
* file-based indexes have new name on each rebuild
* Sanitize file path segments, verify that generated path is within the root dir.
* Add comment and test for cleanOldIndexes.
---
pkg/storage/unified/resource/search.go | 72 +++--
pkg/storage/unified/resource/search_test.go | 61 ++++
pkg/storage/unified/search/bleve.go | 340 ++++++++++++++------
pkg/storage/unified/search/bleve_test.go | 310 +++++++++++++++---
4 files changed, 629 insertions(+), 154 deletions(-)
diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go
index 2cc2b6f8b46..0a88d9a035a 100644
--- a/pkg/storage/unified/resource/search.go
+++ b/pkg/storage/unified/resource/search.go
@@ -14,6 +14,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
+ "golang.org/x/sync/singleflight"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -80,28 +81,16 @@ type ResourceIndex interface {
// SearchBackend contains the technology specific logic to support search
type SearchBackend interface {
- // This will return nil if the key does not exist
+ // GetIndex returns existing index, or nil.
GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error)
- // Build an index from scratch
- BuildIndex(ctx context.Context,
- key NamespacedResource,
+ // BuildIndex builds an index from scratch.
+ // Depending on the size, the backend may choose different options (eg: memory vs disk).
+ // The last known resource version can be used to detect that nothing has changed, and existing on-disk index can be reused.
+ // The builder will write all documents before returning.
+ BuildIndex(ctx context.Context, key NamespacedResource, size int64, resourceVersion int64, nonStandardFields SearchableDocumentFields, builder func(index ResourceIndex) (int64, error)) (ResourceIndex, error)
- // When the size is known, it will be passed along here
- // Depending on the size, the backend may choose different options (eg: memory vs disk)
- size int64,
-
- // The last known resource version (can be used to know that nothing has changed)
- resourceVersion int64,
-
- // The non-standard index fields
- fields SearchableDocumentFields,
-
- // The builder will write all documents before returning
- builder func(index ResourceIndex) (int64, error),
- ) (ResourceIndex, error)
-
- // Gets the total number of documents across all indexes
+ // TotalDocs returns the total number of documents across all indexes.
TotalDocs() int64
}
@@ -120,6 +109,8 @@ type searchSupport struct {
initMinSize int
initMaxSize int
+ buildIndex singleflight.Group
+
// Index queue processors
indexQueueProcessorsMutex sync.Mutex
indexQueueProcessors map[string]*indexQueueProcessor
@@ -608,24 +599,53 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso
ctx, span := s.tracer.Start(ctx, tracingPrexfixSearch+"GetOrCreateIndex")
defer span.End()
- // TODO???
- // We want to block while building the index and return the same index for the key
- // simple mutex not great... we don't want to block while anything in building, just the same key
idx, err := s.search.GetIndex(ctx, key)
if err != nil {
return nil, err
}
- if idx == nil {
- idx, _, err = s.build(ctx, key, 10, 0) // unknown size and RV
+ if idx != nil {
+ return idx, nil
+ }
+
+ idxInt, err, _ := s.buildIndex.Do(key.String(), func() (interface{}, error) {
+ // Recheck if some other goroutine managed to build an index in the meantime.
+ // (That is, it finished running this function and stored the index into the cache)
+ idx, err := s.search.GetIndex(ctx, key)
+ if err == nil && idx != nil {
+ return idx, nil
+ }
+
+ // Get correct value of size + RV for building the index. This is important for our Bleve
+ // backend to decide whether to build index in-memory or as file-based.
+ stats, err := s.storage.GetResourceStats(ctx, key.Namespace, 0)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get resource stats: %w", err)
+ }
+
+ size := int64(0)
+ rv := int64(0)
+ for _, stat := range stats {
+ if stat.Namespace == key.Namespace && stat.Group == key.Group && stat.Resource == key.Resource {
+ size = stat.Count
+ rv = stat.ResourceVersion
+ break
+ }
+ }
+
+ idx, _, err = s.build(ctx, key, size, rv)
if err != nil {
return nil, fmt.Errorf("error building search index, %w", err)
}
if idx == nil {
return nil, fmt.Errorf("nil index after build")
}
+ return idx, nil
+ })
+ if err != nil {
+ return nil, err
}
- return idx, nil
+ return idxInt.(ResourceIndex), nil
}
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (ResourceIndex, int64, error) {
@@ -640,8 +660,6 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size
}
fields := s.builders.GetFields(nsr)
- logger.Debug("Building index", "resource", nsr.Resource, "size", size, "rv", rv)
-
index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, func(index ResourceIndex) (int64, error) {
rv, err = s.storage.ListIterator(ctx, &resourcepb.ListRequest{
Limit: 1000000000000, // big number
diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go
index b474eac8f9d..25f7f3280c0 100644
--- a/pkg/storage/unified/resource/search_test.go
+++ b/pkg/storage/unified/resource/search_test.go
@@ -2,7 +2,9 @@ package resource
import (
"context"
+ "sync"
"testing"
+ "time"
"github.com/grafana/authlib/types"
"github.com/stretchr/testify/mock"
@@ -96,6 +98,7 @@ func (m *mockStorageBackend) ListHistory(ctx context.Context, req *resourcepb.Li
// mockSearchBackend implements SearchBackend for testing with tracking capabilities
type mockSearchBackend struct {
+ mu sync.Mutex
buildIndexCalls []buildIndexCall
buildEmptyIndexCalls []buildEmptyIndexCall
}
@@ -129,6 +132,9 @@ func (m *mockSearchBackend) BuildIndex(ctx context.Context, key NamespacedResour
return nil, err
}
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
// Determine if this is an empty index based on size
// Empty indexes are characterized by size == 0
if size == 0 {
@@ -271,3 +277,58 @@ func TestBuildIndexes_MaxCountThreshold(t *testing.T) {
})
}
}
+
+func TestSearchGetOrCreateIndex(t *testing.T) {
+ // Setup mock implementations
+ storage := &mockStorageBackend{
+ resourceStats: []ResourceStats{
+ {NamespacedResource: NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"}, Count: 50, ResourceVersion: 11111111},
+ },
+ }
+ search := &mockSearchBackend{
+ buildIndexCalls: []buildIndexCall{},
+ buildEmptyIndexCalls: []buildEmptyIndexCall{},
+ }
+ supplier := &TestDocumentBuilderSupplier{
+ GroupsResources: map[string]string{
+ "group": "resource",
+ },
+ }
+
+ // Create search support with the specified initMaxSize
+ opts := SearchOptions{
+ Backend: search,
+ Resources: supplier,
+ WorkerThreads: 1,
+ InitMinCount: 1, // set min count to default for this test
+ InitMaxCount: 0,
+ }
+
+ support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil)
+ require.NoError(t, err)
+ require.NotNil(t, support)
+
+ start := make(chan struct{})
+
+ const concurrency = 100
+ wg := sync.WaitGroup{}
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ _, _ = support.getOrCreateIndex(context.Background(), NamespacedResource{Namespace: "ns", Group: "group", Resource: "resource"})
+ }()
+ }
+
+ // Wait a bit for goroutines to start (hopefully)
+ time.Sleep(10 * time.Millisecond)
+ // Unblock all goroutines.
+ close(start)
+ wg.Wait()
+
+ require.NotEmpty(t, search.buildIndexCalls)
+ require.Less(t, len(search.buildIndexCalls), concurrency, "Should not have built index more than a few times (ideally once)")
+ require.Equal(t, int64(50), search.buildIndexCalls[0].size)
+ require.Equal(t, int64(11111111), search.buildIndexCalls[0].resourceVersion)
+}
diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go
index 503bd7a8dfb..a2415c528b0 100644
--- a/pkg/storage/unified/search/bleve.go
+++ b/pkg/storage/unified/search/bleve.go
@@ -3,6 +3,7 @@ package search
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"math"
@@ -11,6 +12,7 @@ import (
"slices"
"strconv"
"strings"
+ "sync"
"time"
"github.com/blevesearch/bleve/v2"
@@ -31,7 +33,6 @@ import (
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
- "github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -39,8 +40,6 @@ import (
const (
// tracingPrexfixBleve is the prefix used for tracing spans in the Bleve backend
tracingPrexfixBleve = "unified_search.bleve."
- // Default index cache cleanup TTL is 1 minute
- indexCacheCleanupInterval = time.Minute
)
var _ resource.SearchBackend = &bleveBackend{}
@@ -57,7 +56,7 @@ type BleveOptions struct {
// ?? not totally sure the units
BatchSize int
- // Index cache TTL for bleve indices
+ // Index cache TTL for bleve indices. 0 disables expiration for in-memory indexes.
IndexCacheTTL time.Duration
}
@@ -65,9 +64,9 @@ type bleveBackend struct {
tracer trace.Tracer
log *slog.Logger
opts BleveOptions
- start time.Time
- cache *localcache.CacheService
+ cacheMx sync.RWMutex
+ cache map[resource.NamespacedResource]*bleveIndex
features featuremgmt.FeatureToggles
indexMetrics *resource.BleveIndexMetrics
@@ -77,6 +76,12 @@ func NewBleveBackend(opts BleveOptions, tracer trace.Tracer, features featuremgm
if opts.Root == "" {
return nil, fmt.Errorf("bleve backend missing root folder configuration")
}
+ absRoot, err := filepath.Abs(opts.Root)
+ if err != nil {
+ return nil, fmt.Errorf("error getting absolute path for bleve root folder %w", err)
+ }
+ opts.Root = absRoot
+
root, err := os.Stat(opts.Root)
if err != nil {
return nil, fmt.Errorf("error opening bleve root folder %w", err)
@@ -85,35 +90,64 @@ func NewBleveBackend(opts BleveOptions, tracer trace.Tracer, features featuremgm
return nil, fmt.Errorf("bleve root is configured against a file (not folder)")
}
- bleveBackend := &bleveBackend{
+ be := &bleveBackend{
log: slog.Default().With("logger", "bleve-backend"),
tracer: tracer,
- cache: localcache.New(opts.IndexCacheTTL, indexCacheCleanupInterval),
+ cache: map[resource.NamespacedResource]*bleveIndex{},
opts: opts,
- start: time.Now(),
features: features,
indexMetrics: indexMetrics,
}
- go bleveBackend.updateIndexSizeMetric(opts.Root)
+ go be.updateIndexSizeMetric(opts.Root)
- return bleveBackend, nil
+ return be, nil
}
-// This will return nil if the key does not exist
-func (b *bleveBackend) GetIndex(ctx context.Context, key resource.NamespacedResource) (resource.ResourceIndex, error) {
- val, ok := b.cache.Get(key.String())
- if !ok {
+// GetIndex will return nil if the key does not exist
+func (b *bleveBackend) GetIndex(_ context.Context, key resource.NamespacedResource) (resource.ResourceIndex, error) {
+ idx := b.getCachedIndex(key)
+ // Avoid returning typed nils.
+ if idx == nil {
return nil, nil
}
-
- idx, ok := val.(*bleveIndex)
- if !ok {
- return nil, fmt.Errorf("cache item is not a bleve index: %s", key.String())
- }
return idx, nil
}
+func (b *bleveBackend) getCachedIndex(key resource.NamespacedResource) *bleveIndex {
+ // Check index with read-lock first.
+ b.cacheMx.RLock()
+ val := b.cache[key]
+ b.cacheMx.RUnlock()
+
+ if val == nil {
+ return nil
+ }
+
+ if val.expiration.IsZero() || val.expiration.After(time.Now()) {
+ // Not expired yet.
+ return val
+ }
+
+ // We're dealing with expired index. We need to remove it from the cache and close it.
+ b.cacheMx.Lock()
+ val = b.cache[key]
+ delete(b.cache, key)
+ b.cacheMx.Unlock()
+
+ if val == nil {
+ return nil
+ }
+
+ // Index is no longer in the cache, but we need to close it.
+ err := val.index.Close()
+ if err != nil {
+ b.log.Error("failed to close index", "key", key, "err", err)
+ }
+ b.log.Info("index evicted from cache", "key", key)
+ return nil
+}
+
// updateIndexSizeMetric sets the total size of all file-based indices metric.
func (b *bleveBackend) updateIndexSizeMetric(indexPath string) {
if b.indexMetrics == nil {
@@ -147,28 +181,21 @@ func (b *bleveBackend) updateIndexSizeMetric(indexPath string) {
}
}
-// Build an index from scratch
-func (b *bleveBackend) BuildIndex(ctx context.Context,
+// BuildIndex builds an index from scratch.
+// If built successfully, the new index replaces the old index in the cache (if there was any).
+func (b *bleveBackend) BuildIndex(
+ ctx context.Context,
key resource.NamespacedResource,
-
- // When the size is known, it will be passed along here
- // Depending on the size, the backend may choose different options (eg: memory vs disk)
size int64,
-
- // The last known resource version can be used to know that we can skip calling the builder
resourceVersion int64,
-
- // the non-standard searchable fields
fields resource.SearchableDocumentFields,
-
- // The builder will write all documents before returning
builder func(index resource.ResourceIndex) (int64, error),
) (resource.ResourceIndex, error) {
_, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex")
defer span.End()
- var err error
var index bleve.Index
+ fileIndexName := "" // Name of the file-based index, or empty for in-memory indexes.
build := true
mapper, err := GetBleveMappings(fields)
@@ -176,61 +203,62 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
return nil, err
}
- if size > b.opts.FileThreshold {
- resourceDir := filepath.Join(b.opts.Root, key.Namespace,
- fmt.Sprintf("%s.%s", key.Resource, key.Group),
- )
- fname := fmt.Sprintf("rv%d", resourceVersion)
- if resourceVersion == 0 {
- fname = b.start.Format("tmp-20060102-150405")
- }
- dir := filepath.Join(resourceDir, fname)
- if !isValidPath(dir, b.opts.Root) {
- b.log.Error("Directory is not valid", "directory", dir)
- }
- if resourceVersion > 0 {
- info, _ := os.Stat(dir)
- if info != nil && info.IsDir() {
- index, err = bleve.Open(dir) // NOTE, will use the same mappings!!!
- if err == nil {
- found, err := index.DocCount()
- if err != nil || int64(found) != size {
- b.log.Info("this size changed since the last time the index opened")
- _ = index.Close()
+ cachedIndex := b.getCachedIndex(key)
- // Pick a new file name
- fname = b.start.Format("tmp-20060102-150405-changed")
- dir = filepath.Join(resourceDir, fname)
- index = nil
- } else {
- build = false // no need to build the index
- }
+ logWithDetails := b.log.With("namespace", key.Namespace, "group", key.Group, "resource", key.Resource, "size", size, "rv", resourceVersion)
+
+ resourceDir := filepath.Join(b.opts.Root, cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group)))
+
+ if size > b.opts.FileThreshold {
+ // We only check for the existing file-based index if we don't already have an open index for this key.
+ // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes)
+ // If we do have an unexpired cached index already, we always build a new index from scratch.
+ if cachedIndex == nil && resourceVersion > 0 {
+ index, fileIndexName = b.findPreviousFileBasedIndex(resourceDir, resourceVersion, size)
+ }
+
+ if index != nil {
+ build = false
+ logWithDetails.Debug("Existing index found on filesystem", "directory", filepath.Join(resourceDir, fileIndexName))
+ } else {
+ // Building index from scratch. Index name has a time component in it to be unique, but if
+ // we happen to create non-unique name, we bump the time and try again.
+
+ indexDir := ""
+ now := time.Now()
+ for index == nil {
+ fileIndexName = formatIndexName(time.Now(), resourceVersion)
+ indexDir = filepath.Join(resourceDir, fileIndexName)
+ if !isPathWithinRoot(indexDir, b.opts.Root) {
+ return nil, fmt.Errorf("invalid path %s", indexDir)
+ }
+
+ index, err = bleve.New(indexDir, mapper)
+ if errors.Is(err, bleve.ErrorIndexPathExists) {
+ now = now.Add(time.Second) // Bump time for next try
+ index = nil // Bleve actually returns non-nil value with ErrorIndexPathExists
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error creating new bleve index: %s %w", indexDir, err)
}
}
+
+ logWithDetails.Info("Building index using filesystem", "directory", indexDir)
}
- if index == nil {
- index, err = bleve.New(dir, mapper)
- if err != nil {
- err = fmt.Errorf("error creating new bleve index: %s %w", dir, err)
- }
- }
-
- // Start a background task to cleanup the old index directories
- if index != nil && err == nil {
- go b.cleanOldIndexes(resourceDir, fname)
- }
if b.indexMetrics != nil {
b.indexMetrics.IndexTenants.WithLabelValues("file").Inc()
}
} else {
index, err = bleve.NewMemOnly(mapper)
+ if err != nil {
+ return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err)
+ }
if b.indexMetrics != nil {
b.indexMetrics.IndexTenants.WithLabelValues("memory").Inc()
}
- }
- if err != nil {
- return nil, err
+ logWithDetails.Info("Building index using memory")
}
// Batch all the changes
@@ -249,28 +277,72 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
}
if build {
+ start := time.Now()
_, err = builder(idx)
if err != nil {
return nil, err
}
+ elapsed := time.Since(start)
+ logWithDetails.Info("Finished building index", "elapsed", elapsed)
}
- b.cache.SetDefault(key.String(), idx)
+ // Set expiration after building the index. Only expire in-memory indexes.
+ if fileIndexName == "" && b.opts.IndexCacheTTL > 0 {
+ idx.expiration = time.Now().Add(b.opts.IndexCacheTTL)
+ }
+
+ // Store the index in the cache.
+ if idx.expiration.IsZero() {
+ logWithDetails.Info("Storing index in cache, with no expiration", "key", key)
+ } else {
+ logWithDetails.Info("Storing index in cache", "key", key, "expiration", idx.expiration)
+ }
+
+ b.cacheMx.Lock()
+ prev := b.cache[key]
+ b.cache[key] = idx
+ b.cacheMx.Unlock()
+
+ // If there was a previous index in the cache, close it.
+ if prev != nil {
+ err := prev.index.Close()
+ if err != nil {
+ logWithDetails.Error("failed to close previous index", "key", key, "err", err)
+ }
+ }
+
+ // Start a background task to cleanup the old index directories. If we have built a new file-based index,
+ // the new name is ignored. If we have created in-memory index and fileIndexName is empty, all old directories can be removed.
+ go b.cleanOldIndexes(resourceDir, fileIndexName)
+
return idx, nil
}
-func (b *bleveBackend) cleanOldIndexes(dir string, skip string) {
+func cleanFileSegment(input string) string {
+ input = strings.ReplaceAll(input, string(filepath.Separator), "_")
+ input = strings.ReplaceAll(input, "..", "_")
+ return input
+}
+
+// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName".
+// "skipName" can be empty.
+func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) {
files, err := os.ReadDir(dir)
if err != nil {
+ if os.IsNotExist(err) {
+ return
+ }
b.log.Warn("error cleaning folders from", "directory", dir, "error", err)
return
}
for _, file := range files {
- if file.IsDir() && file.Name() != skip {
+ if file.IsDir() && file.Name() != skipName {
fpath := filepath.Join(dir, file.Name())
- if !isValidPath(dir, b.opts.Root) {
- b.log.Error("Path is not valid", "directory", fpath, "error", err)
+ if !isPathWithinRoot(fpath, b.opts.Root) {
+ b.log.Warn("Skipping cleanup of directory", "directory", fpath)
+ continue
}
+
err = os.RemoveAll(fpath)
if err != nil {
b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err)
@@ -281,31 +353,45 @@ func (b *bleveBackend) cleanOldIndexes(dir string, skip string) {
}
}
-// isValidPath does a sanity check in case it tries to access a different dir
-func isValidPath(path, safeDir string) bool {
- if path == "" || safeDir == "" {
+// isPathWithinRoot verifies that path is within given absoluteRoot.
+func isPathWithinRoot(path, absoluteRoot string) bool {
+ if path == "" || absoluteRoot == "" {
return false
}
- cleanPath := filepath.Clean(path)
- cleanSafeDir := filepath.Clean(safeDir)
- rel, err := filepath.Rel(cleanSafeDir, cleanPath)
+ path, err := filepath.Abs(path)
if err != nil {
return false
}
- return !strings.HasPrefix(rel, "..") && !strings.Contains(rel, "\\")
+ if !strings.HasPrefix(path, absoluteRoot) {
+ return false
+ }
+ return true
+}
+
+// cacheKeys returns list of keys for indexes in the cache (including possibly expired ones).
+func (b *bleveBackend) cacheKeys() []resource.NamespacedResource {
+ b.cacheMx.RLock()
+ defer b.cacheMx.RUnlock()
+
+ keys := make([]resource.NamespacedResource, 0, len(b.cache))
+ for k := range b.cache {
+ keys = append(keys, k)
+ }
+ return keys
}
// TotalDocs returns the total number of documents across all indices
func (b *bleveBackend) TotalDocs() int64 {
var totalDocs int64
- for _, v := range b.cache.Items() {
- idx, ok := v.Object.(*bleveIndex)
- if !ok {
- b.log.Warn("cache item is not a bleve index", "key", v.Object)
+ // We iterate over keys and call getCachedIndex for each index individually.
+ // We do this to avoid keeping a lock for the entire TotalDocs function, since DocCount may be slow (due to disk access).
+ // Calling getCachedIndex also handles index expiration.
+ for _, key := range b.cacheKeys() {
+ idx := b.getCachedIndex(key)
+ if idx == nil {
continue
}
-
c, err := idx.index.DocCount()
if err != nil {
continue
@@ -315,6 +401,74 @@ func (b *bleveBackend) TotalDocs() int64 {
return totalDocs
}
+func formatIndexName(now time.Time, resourceVersion int64) string {
+ timestamp := now.Format("20060102-150405")
+ return fmt.Sprintf("%s-%d", timestamp, resourceVersion)
+}
+
+func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string, resourceVersion int64, size int64) (bleve.Index, string) {
+ entries, err := os.ReadDir(resourceDir)
+ if err != nil {
+ return nil, ""
+ }
+
+ indexName := ""
+ for _, ent := range entries {
+ if !ent.IsDir() {
+ continue
+ }
+
+ parts := strings.Split(ent.Name(), "-")
+ if len(parts) != 3 {
+ continue
+ }
+
+ // Last part is resourceVersion
+ indexRv, err := strconv.ParseInt(parts[2], 10, 64)
+ if err != nil {
+ continue
+ }
+ if indexRv != resourceVersion {
+ continue
+ }
+ indexName = ent.Name()
+ break
+ }
+
+ if indexName == "" {
+ return nil, ""
+ }
+
+ indexDir := filepath.Join(resourceDir, indexName)
+ idx, err := bleve.Open(indexDir)
+ if err != nil {
+ return nil, ""
+ }
+
+ cnt, err := idx.DocCount()
+ if err != nil {
+ _ = idx.Close()
+ return nil, ""
+ }
+
+ if uint64(size) != cnt {
+ _ = idx.Close()
+ return nil, ""
+ }
+
+ return idx, indexName
+}
+
+func (b *bleveBackend) closeAllIndexes() {
+ b.cacheMx.Lock()
+ defer b.cacheMx.Unlock()
+
+ for key, idx := range b.cache {
+ _ = idx.index.Close()
+ delete(b.cache, key)
+ }
+}
+
type bleveIndex struct {
key resource.NamespacedResource
index bleve.Index
@@ -322,6 +476,10 @@ type bleveIndex struct {
standard resource.SearchableDocumentFields
fields resource.SearchableDocumentFields
+ // When to expire and close the index. Zero value = no expiration.
+ // We only expire in-memory indexes.
+ expiration time.Time
+
// The values returned with all
allFields []*resourcepb.ResourceTableColumnDefinition
features featuremgmt.FeatureToggles
diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go
index a2ea6afaced..b6e2bd05611 100644
--- a/pkg/storage/unified/search/bleve_test.go
+++ b/pkg/storage/unified/search/bleve_test.go
@@ -8,7 +8,9 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
+ "github.com/blevesearch/bleve/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -671,65 +673,301 @@ func TestSafeInt64ToInt(t *testing.T) {
}
}
-func Test_isValidPath(t *testing.T) {
+func Test_isPathWithinRoot(t *testing.T) {
tests := []struct {
- name string
- dir string
- safeDir string
- want bool
+ name string
+ dir string
+ root string
+ want bool
}{
{
- name: "valid path",
- dir: "/path/to/my-file/",
- safeDir: "/path/to/",
- want: true,
+ name: "valid path",
+ dir: "/path/to/my-file/",
+ root: "/path/to/",
+ want: true,
},
{
- name: "valid path without trailing slash",
- dir: "/path/to/my-file",
- safeDir: "/path/to",
- want: true,
+ name: "valid path without trailing slash",
+ dir: "/path/to/my-file",
+ root: "/path/to",
+ want: true,
},
{
- name: "path with double slashes",
- dir: "/path//to//my-file/",
- safeDir: "/path/to/",
- want: true,
+ name: "path with double slashes",
+ dir: "/path//to//my-file/",
+ root: "/path/to/",
+ want: true,
},
{
- name: "invalid path: ..",
- dir: "/path/../above/",
- safeDir: "/path/to/",
+ name: "invalid path: ..",
+ dir: "/path/../above/",
+ root: "/path/to/",
},
{
- name: "invalid path: \\",
- dir: "\\path/to",
- safeDir: "/path/to/",
+ name: "invalid path: \\",
+ dir: "\\path/to",
+ root: "/path/to/",
},
{
- name: "invalid path: not under safe dir",
- dir: "/path/to.txt",
- safeDir: "/path/to/",
+ name: "invalid path: not under safe dir",
+ dir: "/path/to.txt",
+ root: "/path/to/",
},
{
- name: "invalid path: empty paths",
- dir: "",
- safeDir: "/path/to/",
+ name: "invalid path: empty paths",
+ dir: "",
+ root: "/path/to/",
},
{
- name: "invalid path: different path",
- dir: "/other/path/to/my-file/",
- safeDir: "/Some/other/path",
+ name: "invalid path: different path",
+ dir: "/other/path/to/my-file/",
+ root: "/Some/other/path",
},
{
- name: "invalid path: empty safe path",
- dir: "/path/to/",
- safeDir: "",
+ name: "invalid path: empty safe path",
+ dir: "/path/to/",
+ root: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, isValidPath(tt.dir, tt.safeDir))
+ require.Equal(t, tt.want, isPathWithinRoot(tt.dir, tt.root))
})
}
}
+
+func setupBleveBackend(t *testing.T, fileThreshold int, cacheTTL time.Duration, dir string) *bleveBackend {
+ if dir == "" {
+ dir = t.TempDir()
+ }
+ backend, err := NewBleveBackend(BleveOptions{
+ Root: dir,
+ FileThreshold: int64(fileThreshold),
+ IndexCacheTTL: cacheTTL,
+ }, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering), nil)
+ require.NoError(t, err)
+ require.NotNil(t, backend)
+ t.Cleanup(backend.closeAllIndexes)
+ return backend
+}
+
+func TestBleveInMemoryIndexExpiration(t *testing.T) {
+ backend := setupBleveBackend(t, 5, time.Nanosecond, "")
+
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ builtIndex, err := backend.BuildIndex(context.Background(), ns, 1 /* below FileThreshold */, 100, nil, indexTestDocs(ns, 1))
+ require.NoError(t, err)
+
+ // Wait for index expiration, which is 1ns
+ time.Sleep(10 * time.Millisecond)
+ idx, err := backend.GetIndex(context.Background(), ns)
+ require.NoError(t, err)
+ require.Nil(t, idx)
+
+ // Verify that builtIndex is now closed.
+ _, err = builtIndex.DocCount(context.Background(), "")
+ require.ErrorIs(t, err, bleve.ErrorIndexClosed)
+}
+
+func TestBleveFileIndexExpiration(t *testing.T) {
+ backend := setupBleveBackend(t, 5, time.Nanosecond, "")
+
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ // size=100 is above FileThreshold, this will be file-based index
+ builtIndex, err := backend.BuildIndex(context.Background(), ns, 100, 100, nil, indexTestDocs(ns, 1))
+ require.NoError(t, err)
+
+ // Wait for index expiration, which is 1ns
+ time.Sleep(10 * time.Millisecond)
+ idx, err := backend.GetIndex(context.Background(), ns)
+ require.NoError(t, err)
+ require.NotNil(t, idx)
+
+ // Verify that builtIndex is still open.
+ cnt, err := builtIndex.DocCount(context.Background(), "")
+ require.NoError(t, err)
+ require.Equal(t, int64(1), cnt)
+}
+
+func TestFileIndexIsReusedOnSameSizeAndRV(t *testing.T) {
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ tmpDir := t.TempDir()
+
+ backend1 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ _, err := backend1.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, indexTestDocs(ns, 10))
+ require.NoError(t, err)
+ backend1.closeAllIndexes()
+
+ // We open new backend using same directory, and run indexing with same size (10) and RV (100). This should reuse existing index, and skip indexing.
+ backend2 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 100, nil, indexTestDocs(ns, 1000))
+ require.NoError(t, err)
+
+ // Verify that we're reusing existing index and there is only 10 documents in it, not 1000.
+ cnt, err := idx.DocCount(context.Background(), "")
+ require.NoError(t, err)
+ require.Equal(t, int64(10), cnt)
+}
+
+func TestFileIndexIsNotReusedOnDifferentSize(t *testing.T) {
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ tmpDir := t.TempDir()
+
+ backend1 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, indexTestDocs(ns, 10))
+ require.NoError(t, err)
+ backend1.closeAllIndexes()
+
+ // We open new backend using same directory, but with different size. Index should be rebuilt.
+ backend2 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ idx, err := backend2.BuildIndex(context.Background(), ns, 100, 100, nil, indexTestDocs(ns, 100))
+ require.NoError(t, err)
+
+ // Verify that index has updated number of documents.
+ cnt, err := idx.DocCount(context.Background(), "")
+ require.NoError(t, err)
+ require.Equal(t, int64(100), cnt)
+}
+
+func TestFileIndexIsNotReusedOnDifferentRV(t *testing.T) {
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ tmpDir := t.TempDir()
+
+ backend1 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, indexTestDocs(ns, 10))
+ require.NoError(t, err)
+ backend1.closeAllIndexes()
+
+ // We open new backend using same directory, but with different RV. Index should be rebuilt.
+ backend2 := setupBleveBackend(t, 5, time.Nanosecond, tmpDir)
+ idx, err := backend2.BuildIndex(context.Background(), ns, 10 /* file based */, 999999, nil, indexTestDocs(ns, 100))
+ require.NoError(t, err)
+
+ // Verify that index has updated number of documents.
+ cnt, err := idx.DocCount(context.Background(), "")
+ require.NoError(t, err)
+ require.Equal(t, int64(100), cnt)
+}
+
+func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) {
+ ns := resource.NamespacedResource{
+ Namespace: "test",
+ Group: "group",
+ Resource: "resource",
+ }
+
+ for name, testCase := range map[string]struct {
+ firstInMemory bool
+ secondInMemory bool
+ }{
+ "in-memory, in-memory": {true, true},
+ "in-memory, file": {true, false},
+ "file, in-memory": {false, true},
+ "file, file": {false, false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ backend := setupBleveBackend(t, 5, time.Nanosecond, "")
+
+ firstSize := 100
+ if testCase.firstInMemory {
+ firstSize = 1
+ }
+ firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, indexTestDocs(ns, firstSize))
+ require.NoError(t, err)
+
+ secondSize := 100
+ if testCase.firstInMemory {
+ secondSize = 1
+ }
+ secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, indexTestDocs(ns, secondSize))
+ require.NoError(t, err)
+
+ // Verify that first and second index are different, and first one is now closed.
+ require.NotEqual(t, firstIndex, secondIndex)
+
+ _, err = firstIndex.DocCount(context.Background(), "")
+ require.ErrorIs(t, err, bleve.ErrorIndexClosed)
+
+ cnt, err := secondIndex.DocCount(context.Background(), "")
+ require.NoError(t, err)
+ require.Equal(t, int64(secondSize), cnt)
+ })
+ }
+}
+
+func indexTestDocs(ns resource.NamespacedResource, docs int) func(index resource.ResourceIndex) (int64, error) {
+ return func(index resource.ResourceIndex) (int64, error) {
+ var items []*resource.BulkIndexItem
+ for i := 0; i < docs; i++ {
+ items = append(items, &resource.BulkIndexItem{
+ Action: resource.ActionIndex,
+ Doc: &resource.IndexableDocument{
+ Key: &resourcepb.ResourceKey{
+ Namespace: ns.Namespace,
+ Group: ns.Group,
+ Resource: ns.Resource,
+ Name: fmt.Sprintf("doc%d", i),
+ },
+ Title: fmt.Sprintf("Document %d", i),
+ },
+ })
+ }
+
+ err := index.BulkIndex(&resource.BulkIndexRequest{Items: items})
+ return int64(docs), err
+ }
+}
+
+func TestCleanOldIndexes(t *testing.T) {
+ dir := t.TempDir()
+
+ b := setupBleveBackend(t, 5, time.Nanosecond, dir)
+
+ t.Run("with skip", func(t *testing.T) {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-1/a"), 0750))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-2/b"), 0750))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-3/c"), 0750))
+
+ b.cleanOldIndexes(dir, "index-2")
+ files, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ require.Equal(t, "index-2", files[0].Name())
+ })
+
+ t.Run("without skip", func(t *testing.T) {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-1/a"), 0750))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-2/b"), 0750))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "index-3/c"), 0750))
+
+ b.cleanOldIndexes(dir, "")
+ files, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, files, 0)
+ })
+}
From ff8a9fa4620c186d0c75706ff5da0e3312a0027d Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 12:23:07 +0000
Subject: [PATCH 22/33] Update dependency lerna to v8.2.3 (#107957)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package.json | 2 +-
yarn.lock | 69 +++++++++++++++++++++++++++++-----------------------
2 files changed, 40 insertions(+), 31 deletions(-)
diff --git a/package.json b/package.json
index 01192b61a04..16011f713df 100644
--- a/package.json
+++ b/package.json
@@ -217,7 +217,7 @@
"jest-watch-typeahead": "^2.2.2",
"jimp": "^1.6.0",
"jsdom-testing-mocks": "^1.13.1",
- "lerna": "8.2.1",
+ "lerna": "8.2.3",
"mini-css-extract-plugin": "2.9.2",
"msw": "2.10.3",
"mutationobserver-shim": "0.3.7",
diff --git a/yarn.lock b/yarn.lock
index ab588cc29c4..6ed7f015d81 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4759,9 +4759,9 @@ __metadata:
languageName: node
linkType: hard
-"@lerna/create@npm:8.2.1":
- version: 8.2.1
- resolution: "@lerna/create@npm:8.2.1"
+"@lerna/create@npm:8.2.3":
+ version: 8.2.3
+ resolution: "@lerna/create@npm:8.2.3"
dependencies:
"@npmcli/arborist": "npm:7.5.4"
"@npmcli/package-json": "npm:5.2.0"
@@ -4786,7 +4786,6 @@ __metadata:
get-stream: "npm:6.0.0"
git-url-parse: "npm:14.0.0"
glob-parent: "npm:6.0.2"
- globby: "npm:11.1.0"
graceful-fs: "npm:4.2.11"
has-unicode: "npm:2.0.1"
ini: "npm:^1.3.8"
@@ -4821,9 +4820,10 @@ __metadata:
slash: "npm:^3.0.0"
ssri: "npm:^10.0.6"
string-width: "npm:^4.2.3"
- strong-log-transformer: "npm:2.1.0"
tar: "npm:6.2.1"
temp-dir: "npm:1.0.0"
+ through: "npm:2.3.8"
+ tinyglobby: "npm:0.2.12"
upath: "npm:2.0.1"
uuid: "npm:^10.0.0"
validate-npm-package-license: "npm:^3.0.4"
@@ -4833,7 +4833,7 @@ __metadata:
write-pkg: "npm:4.0.0"
yargs: "npm:17.7.2"
yargs-parser: "npm:21.1.1"
- checksum: 10/802db88edad8967afcbf499f68491139965209137ec92f402ac838452079f143701d6f9abd9832b3506a7e1c56e01ea9c09ffd6f8782b1d9202413756fcfd708
+ checksum: 10/1264bf324de2c83377dbc0b49c6731b9e27401c552c34601846b012c46aee496c9a3bcdeba87e59cfaf8b54d3c82f7f3ece62ff4c77ca7b3a85acfc021b7cbb2
languageName: node
linkType: hard
@@ -15349,7 +15349,7 @@ __metadata:
languageName: node
linkType: hard
-"duplexer@npm:^0.1.1, duplexer@npm:^0.1.2":
+"duplexer@npm:^0.1.2":
version: 0.1.2
resolution: "duplexer@npm:0.1.2"
checksum: 10/62ba61a830c56801db28ff6305c7d289b6dc9f859054e8c982abd8ee0b0a14d2e9a8e7d086ffee12e868d43e2bbe8a964be55ddbd8c8957714c87373c7a4f9b0
@@ -16918,6 +16918,18 @@ __metadata:
languageName: node
linkType: hard
+"fdir@npm:^6.4.3":
+ version: 6.4.6
+ resolution: "fdir@npm:6.4.6"
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+ checksum: 10/c186ba387e7b75ccf874a098d9bc5fe0af0e9c52fc56f8eac8e80aa4edb65532684bf2bf769894ff90f53bf221d6136692052d31f07a9952807acae6cbe7ee50
+ languageName: node
+ linkType: hard
+
"fflate@npm:^0.8.2":
version: 0.8.2
resolution: "fflate@npm:0.8.2"
@@ -18031,7 +18043,7 @@ __metadata:
languageName: node
linkType: hard
-"globby@npm:11.1.0, globby@npm:^11.0.0, globby@npm:^11.1.0":
+"globby@npm:^11.0.0, globby@npm:^11.1.0":
version: 11.1.0
resolution: "globby@npm:11.1.0"
dependencies:
@@ -18339,7 +18351,7 @@ __metadata:
json-source-map: "npm:0.6.1"
jsurl: "npm:^0.1.5"
kbar: "npm:0.1.0-beta.45"
- lerna: "npm:8.2.1"
+ lerna: "npm:8.2.3"
leven: "npm:^4.0.0"
lodash: "npm:4.17.21"
logfmt: "npm:^1.3.2"
@@ -21592,11 +21604,11 @@ __metadata:
languageName: node
linkType: hard
-"lerna@npm:8.2.1":
- version: 8.2.1
- resolution: "lerna@npm:8.2.1"
+"lerna@npm:8.2.3":
+ version: 8.2.3
+ resolution: "lerna@npm:8.2.3"
dependencies:
- "@lerna/create": "npm:8.2.1"
+ "@lerna/create": "npm:8.2.3"
"@npmcli/arborist": "npm:7.5.4"
"@npmcli/package-json": "npm:5.2.0"
"@npmcli/run-script": "npm:8.1.0"
@@ -21623,7 +21635,6 @@ __metadata:
get-stream: "npm:6.0.0"
git-url-parse: "npm:14.0.0"
glob-parent: "npm:6.0.2"
- globby: "npm:11.1.0"
graceful-fs: "npm:4.2.11"
has-unicode: "npm:2.0.1"
import-local: "npm:3.1.0"
@@ -21663,9 +21674,10 @@ __metadata:
slash: "npm:3.0.0"
ssri: "npm:^10.0.6"
string-width: "npm:^4.2.3"
- strong-log-transformer: "npm:2.1.0"
tar: "npm:6.2.1"
temp-dir: "npm:1.0.0"
+ through: "npm:2.3.8"
+ tinyglobby: "npm:0.2.12"
typescript: "npm:>=3 < 6"
upath: "npm:2.0.1"
uuid: "npm:^10.0.0"
@@ -21678,7 +21690,7 @@ __metadata:
yargs-parser: "npm:21.1.1"
bin:
lerna: dist/cli.js
- checksum: 10/ebf9fd1af102a8b7e89dcf05e32f92dfa2ce13e77c9788a86eb4828e6a5269e7bf85edf1bcdb4e4ea383f42d872880ad61fc26d304276715b3757fb54cd60d94
+ checksum: 10/3ef9e5c6e2ee20cad0c750817cf628dffa0056f9b87ee4956f641833ac3b06a8fdf50d4cd6ba63a818427c7e6c1482568c9e184f0535fd23239ed55e5eae57a7
languageName: node
linkType: hard
@@ -30018,19 +30030,6 @@ __metadata:
languageName: node
linkType: hard
-"strong-log-transformer@npm:2.1.0":
- version: 2.1.0
- resolution: "strong-log-transformer@npm:2.1.0"
- dependencies:
- duplexer: "npm:^0.1.1"
- minimist: "npm:^1.2.0"
- through: "npm:^2.3.4"
- bin:
- sl-log-transformer: bin/sl-log-transformer.js
- checksum: 10/2fd14eb0a68893fdadefd89f964df404e3d637729c48aca015eb12d1c47455dee28b2522ad7150de23f7a57cce503656585e7644c9cd8532023ea572f8cc5a80
- languageName: node
- linkType: hard
-
"strtok3@npm:^6.2.4":
version: 6.3.0
resolution: "strtok3@npm:6.3.0"
@@ -30574,7 +30573,7 @@ __metadata:
languageName: node
linkType: hard
-"through@npm:2, through@npm:2.3.x, through@npm:>=2.2.7 <3, through@npm:^2.3.4, through@npm:^2.3.6, through@npm:^2.3.8":
+"through@npm:2, through@npm:2.3.8, through@npm:2.3.x, through@npm:>=2.2.7 <3, through@npm:^2.3.6, through@npm:^2.3.8":
version: 2.3.8
resolution: "through@npm:2.3.8"
checksum: 10/5da78346f70139a7d213b65a0106f3c398d6bc5301f9248b5275f420abc2c4b1e77c2abc72d218dedc28c41efb2e7c312cb76a7730d04f9c2d37d247da3f4198
@@ -30630,6 +30629,16 @@ __metadata:
languageName: node
linkType: hard
+"tinyglobby@npm:0.2.12":
+ version: 0.2.12
+ resolution: "tinyglobby@npm:0.2.12"
+ dependencies:
+ fdir: "npm:^6.4.3"
+ picomatch: "npm:^4.0.2"
+ checksum: 10/4ad28701fa9118b32ef0e27f409e0a6c5741e8b02286d50425c1f6f71e6d6c6ded9dd5bbbbb714784b08623c4ec4d150151f1d3d996cfabe0495f908ab4f7002
+ languageName: node
+ linkType: hard
+
"tinyrainbow@npm:^1.2.0":
version: 1.2.0
resolution: "tinyrainbow@npm:1.2.0"
From a8733f9a056ef827d66edb800aead788c4751dd3 Mon Sep 17 00:00:00 2001
From: maicon
Date: Thu, 10 Jul 2025 09:58:35 -0300
Subject: [PATCH 23/33] Fix TestIntegrationFoldersGetAPIEndpointK8S (#107924)
Run TestIntegrationFoldersGetAPIEndpointK8S only for SQLite
Signed-off-by: Maicon Costa
---
pkg/tests/apis/folder/folders_test.go | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go
index 68ffb9f3a4d..befb3fe7b3f 100644
--- a/pkg/tests/apis/folder/folders_test.go
+++ b/pkg/tests/apis/folder/folders_test.go
@@ -18,6 +18,7 @@ import (
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/api/dtos"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
+ "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -1026,7 +1027,10 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
- t.Skip("not working yet")
+
+ if !db.IsTestDbSQLite() {
+ t.Skip("test only on sqlite for now")
+ }
type testCase struct {
description string
From d74aac3da5f62aded2ccfcb447f4105871c84923 Mon Sep 17 00:00:00 2001
From: Matias Chomicki
Date: Thu, 10 Jul 2025 15:18:02 +0200
Subject: [PATCH 24/33] virtualization: check for index (#107963)
* virtualization: check for index
* Add regression test
---
.../logs/components/panel/virtualization.test.ts | 12 ++++++++++++
.../features/logs/components/panel/virtualization.ts | 2 +-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts
index 479b7b4a746..342262aafd1 100644
--- a/public/app/features/logs/components/panel/virtualization.test.ts
+++ b/public/app/features/logs/components/panel/virtualization.test.ts
@@ -67,6 +67,18 @@ describe('Virtualization', () => {
expect(size).toBe(SINGLE_LINE_HEIGHT + DETAILS_HEIGHT);
});
+ test('Should not throw when an undefined index is passed', () => {
+ const size = getLogLineSize(
+ virtualization,
+ [log],
+ container,
+ [],
+ { ...defaultOptions, showTime: true, showDetails: [log], detailsMode: 'inline' },
+ 1 // Index out of bounds
+ );
+ expect(size).toBe(SINGLE_LINE_HEIGHT);
+ });
+
test('Returns the a single line if the line is not loaded yet', () => {
const logs = [log];
const size = getLogLineSize(
diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts
index f9e7676a5f5..ecb475c5bcf 100644
--- a/public/app/features/logs/components/panel/virtualization.ts
+++ b/public/app/features/logs/components/panel/virtualization.ts
@@ -255,7 +255,7 @@ export function getLogLineSize(
}
const gap = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER;
const detailsHeight =
- detailsMode === 'inline' && showDetails.findIndex((log) => log.uid === logs[index].uid) >= 0
+ detailsMode === 'inline' && logs[index] && showDetails.findIndex((log) => log.uid === logs[index].uid) >= 0
? window.innerHeight * (LOG_LINE_DETAILS_HEIGHT / 100) + gap / 2
: 0;
// !logs[index] means the line is not yet loaded by infinite scrolling
From 01269c5dd1109421682ac40563cc4d9ae5cb0c57 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 13:34:38 +0000
Subject: [PATCH 25/33] chore(deps): update dependency postcss to v8.5.6
(#107960)
Update dependency postcss to v8.5.6
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
package.json | 2 +-
yarn.lock | 17 +++--------------
2 files changed, 4 insertions(+), 15 deletions(-)
diff --git a/package.json b/package.json
index 16011f713df..97b0264a180 100644
--- a/package.json
+++ b/package.json
@@ -227,7 +227,7 @@
"pa11y-ci": "^3.1.0",
"pdf-parse": "^1.1.1",
"plop": "^4.0.1",
- "postcss": "8.5.1",
+ "postcss": "8.5.6",
"postcss-loader": "8.1.1",
"postcss-reporter": "7.1.0",
"postcss-scss": "4.0.9",
diff --git a/yarn.lock b/yarn.lock
index 6ed7f015d81..3f99cdeb046 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18381,7 +18381,7 @@ __metadata:
pdf-parse: "npm:^1.1.1"
plop: "npm:^4.0.1"
pluralize: "npm:^8.0.0"
- postcss: "npm:8.5.1"
+ postcss: "npm:8.5.6"
postcss-loader: "npm:8.1.1"
postcss-reporter: "npm:7.1.0"
postcss-scss: "npm:4.0.9"
@@ -23320,7 +23320,7 @@ __metadata:
languageName: node
linkType: hard
-"nanoid@npm:^3.3.11, nanoid@npm:^3.3.8":
+"nanoid@npm:^3.3.11":
version: 3.3.11
resolution: "nanoid@npm:3.3.11"
bin:
@@ -25771,18 +25771,7 @@ __metadata:
languageName: node
linkType: hard
-"postcss@npm:8.5.1":
- version: 8.5.1
- resolution: "postcss@npm:8.5.1"
- dependencies:
- nanoid: "npm:^3.3.8"
- picocolors: "npm:^1.1.1"
- source-map-js: "npm:^1.2.1"
- checksum: 10/1fbd28753143f7f03e4604813639918182b15343c7ad0f4e72f3875fc2cc0b8494c887f55dc05008fad5fbf1e1e908ce2edbbce364a91f84dcefb71edf7cd31d
- languageName: node
- linkType: hard
-
-"postcss@npm:^8.4.33, postcss@npm:^8.4.40, postcss@npm:^8.5.1":
+"postcss@npm:8.5.6, postcss@npm:^8.4.33, postcss@npm:^8.4.40, postcss@npm:^8.5.1":
version: 8.5.6
resolution: "postcss@npm:8.5.6"
dependencies:
From 1f3dc0533cafb84ed306267f18b51b4447d0cf51 Mon Sep 17 00:00:00 2001
From: Misi
Date: Thu, 10 Jul 2025 15:41:00 +0200
Subject: [PATCH 26/33] Auth: Add tracing to auth clients and AuthToken service
(#107878)
* Add tracing to auth clients + authtoken svc
* Fix span names
* Fix ext_jwt.go
* Fix idimpl/service
* Update wire_gen.go
* Add tracing to JWT client
* Lint
---
pkg/server/wire_gen.go | 4 +-
pkg/services/auth/authimpl/auth_token.go | 45 ++++++++++++++++++++
pkg/services/auth/idimpl/service.go | 20 ++++++---
pkg/services/auth/idimpl/service_test.go | 11 ++---
pkg/services/authn/authnimpl/registration.go | 18 ++++----
pkg/services/authn/clients/api_key.go | 17 +++++++-
pkg/services/authn/clients/api_key_test.go | 5 ++-
pkg/services/authn/clients/ext_jwt.go | 8 +++-
pkg/services/authn/clients/ext_jwt_test.go | 3 +-
pkg/services/authn/clients/grafana.go | 13 +++++-
pkg/services/authn/clients/grafana_test.go | 5 ++-
pkg/services/authn/clients/jwt.go | 9 +++-
pkg/services/authn/clients/jwt_test.go | 11 ++---
pkg/services/authn/clients/ldap.go | 13 +++++-
pkg/services/authn/clients/ldap_test.go | 16 +++----
pkg/services/authn/clients/oauth.go | 17 +++++++-
pkg/services/authn/clients/oauth_test.go | 10 +++--
pkg/services/authn/clients/password.go | 9 +++-
pkg/services/authn/clients/password_test.go | 3 +-
pkg/services/authn/clients/proxy.go | 12 +++++-
pkg/services/authn/clients/proxy_test.go | 7 +--
pkg/services/authn/clients/session.go | 7 ++-
pkg/services/authn/clients/session_test.go | 5 ++-
23 files changed, 205 insertions(+), 63 deletions(-)
diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go
index 7b6d39165b3..a1c00aada78 100644
--- a/pkg/server/wire_gen.go
+++ b/pkg/server/wire_gen.go
@@ -648,7 +648,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
if err != nil {
return nil, err
}
- idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer)
+ idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationService, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service12, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service13, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service11, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, noop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {
@@ -1161,7 +1161,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
if err != nil {
return nil, err
}
- idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer)
+ idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationServiceMock, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service12, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service13, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service11, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, noop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {
diff --git a/pkg/services/auth/authimpl/auth_token.go b/pkg/services/auth/authimpl/auth_token.go
index bf95502b11e..e8a00ff8c85 100644
--- a/pkg/services/auth/authimpl/auth_token.go
+++ b/pkg/services/auth/authimpl/auth_token.go
@@ -80,6 +80,9 @@ type UserAuthTokenService struct {
}
func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.CreateToken")
+ defer span.End()
+
token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey)
if err != nil {
return nil, err
@@ -136,6 +139,9 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.Create
}
func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.LookupToken")
+ defer span.End()
+
hashedToken := hashToken(s.cfg.SecretKey, unhashedToken)
var model userAuthToken
var exists bool
@@ -234,6 +240,9 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
}
func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.GetTokenByExternalSessionID")
+ defer span.End()
+
var token userAuthToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.Where("external_session_id = ?", externalSessionID).Get(&token)
@@ -258,14 +267,23 @@ func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context,
}
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.GetExternalSession")
+ defer span.End()
+
return s.externalSessionStore.Get(ctx, externalSessionID)
}
func (s *UserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.FindExternalSessions")
+ defer span.End()
+
return s.externalSessionStore.List(ctx, query)
}
func (s *UserAuthTokenService) UpdateExternalSession(ctx context.Context, externalSessionID int64, cmd *auth.UpdateExternalSessionCommand) error {
+ ctx, span := s.tracer.Start(ctx, "authtoken.UpdateExternalSession")
+ defer span.End()
+
return s.externalSessionStore.Update(ctx, externalSessionID, cmd)
}
@@ -329,6 +347,9 @@ func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateC
}
func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.rotateToken")
+ defer span.End()
+
var clientIPStr string
if clientIP != nil {
clientIPStr = clientIP.String()
@@ -385,6 +406,9 @@ func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.User
}
func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error {
+ ctx, span := s.tracer.Start(ctx, "authtoken.RevokeToken")
+ defer span.End()
+
if token == nil {
return auth.ErrUserTokenNotFound
}
@@ -434,6 +458,9 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
}
func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
+ ctx, span := s.tracer.Start(ctx, "authtoken.RevokeAllUserTokens")
+ defer span.End()
+
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
@@ -466,6 +493,9 @@ func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId i
}
func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
+ ctx, span := s.tracer.Start(ctx, "authtoken.BatchRevokeAllUserTokens")
+ defer span.End()
+
return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
if len(userIds) == 0 {
@@ -507,6 +537,9 @@ func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, use
}
func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.GetUserToken")
+ defer span.End()
+
var result auth.UserToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var token userAuthToken
@@ -526,6 +559,9 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok
}
func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.GetUserTokens")
+ defer span.End()
+
result := []*auth.UserToken{}
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var tokens []*userAuthToken
@@ -554,6 +590,9 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64)
// ActiveTokenCount returns the number of active tokens. If userID is nil, the count is for all users.
func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context, userID *int64) (int64, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.ActiveTokenCount")
+ defer span.End()
+
if userID != nil && *userID < 1 {
return 0, errUserIDInvalid
}
@@ -574,6 +613,9 @@ func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context, userID *int
}
func (s *UserAuthTokenService) DeleteUserRevokedTokens(ctx context.Context, userID int64, window time.Duration) error {
+ ctx, span := s.tracer.Start(ctx, "authtoken.DeleteUserRevokedTokens")
+ defer span.End()
+
return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
query := "DELETE FROM user_auth_token WHERE user_id = ? AND revoked_at > 0 AND revoked_at <= ?"
res, err := sess.Exec(query, userID, time.Now().Add(-window).Unix())
@@ -592,6 +634,9 @@ func (s *UserAuthTokenService) DeleteUserRevokedTokens(ctx context.Context, user
}
func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) {
+ ctx, span := s.tracer.Start(ctx, "authtoken.GetUserRevokedTokens")
+ defer span.End()
+
result := []*auth.UserToken{}
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
var tokens []*userAuthToken
diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go
index 5dec411fdee..cb8e6adc9ce 100644
--- a/pkg/services/auth/idimpl/service.go
+++ b/pkg/services/auth/idimpl/service.go
@@ -8,6 +8,7 @@ import (
"github.com/go-jose/go-jose/v3/jwt"
"github.com/prometheus/client_golang/prometheus"
+ "go.opentelemetry.io/otel/trace"
"golang.org/x/sync/singleflight"
authnlib "github.com/grafana/authlib/authn"
@@ -32,18 +33,18 @@ var _ auth.IDService = (*Service)(nil)
func ProvideService(
cfg *setting.Cfg, signer auth.IDSigner,
- cache remotecache.CacheStorage,
- authnService authn.Service,
- reg prometheus.Registerer,
+ cache remotecache.CacheStorage, authnService authn.Service,
+ reg prometheus.Registerer, tracer trace.Tracer,
) *Service {
s := &Service{
cfg: cfg, logger: log.New("id-service"),
signer: signer, cache: cache,
metrics: newMetrics(reg),
nsMapper: request.GetNamespaceMapper(cfg),
+ tracer: tracer,
}
- authnService.RegisterPostAuthHook(s.hook, 140)
+ authnService.RegisterPostAuthHook(s.SyncIDToken, 140)
return s
}
@@ -55,10 +56,14 @@ type Service struct {
cache remotecache.CacheStorage
si singleflight.Group
metrics *metrics
+ tracer trace.Tracer
nsMapper request.NamespaceMapper
}
func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (string, *authnlib.Claims[authnlib.IDTokenClaims], error) {
+ ctx, span := s.tracer.Start(ctx, "user.sync.SignIdentity")
+ defer span.End()
+
defer func(t time.Time) {
s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds())
}(time.Now())
@@ -140,10 +145,15 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
}
func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error {
+ ctx, span := s.tracer.Start(ctx, "user.sync.RemoveIDToken")
+ defer span.End()
+
return s.cache.Delete(ctx, getCacheKey(id))
}
-func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
+func (s *Service) SyncIDToken(ctx context.Context, identity *authn.Identity, _ *authn.Request) error {
+ ctx, span := s.tracer.Start(ctx, "user.sync.SyncIDToken")
+ defer span.End()
// FIXME(kalleep): we should probably lazy load this
token, idClaims, err := s.SignIdentity(ctx, identity)
if err != nil {
diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go
index bb7ee510e55..8690c5c24bc 100644
--- a/pkg/services/auth/idimpl/service_test.go
+++ b/pkg/services/auth/idimpl/service_test.go
@@ -11,6 +11,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/remotecache"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/idtest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -29,7 +30,7 @@ func Test_ProvideService(t *testing.T) {
},
}
- _ = ProvideService(setting.NewCfg(), nil, nil, authnService, nil)
+ _ = ProvideService(setting.NewCfg(), nil, nil, authnService, nil, tracing.InitializeTracerForTest())
assert.True(t, hookRegistered)
})
}
@@ -51,7 +52,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
- &authntest.FakeService{}, nil,
+ &authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
token, _, err := s.SignIdentity(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser})
require.NoError(t, err)
@@ -61,7 +62,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity with authenticated by if user is externally authenticated", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
- &authntest.FakeService{}, nil,
+ &authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
token, _, err := s.SignIdentity(context.Background(), &authn.Identity{
ID: "1",
@@ -86,7 +87,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign identity with authenticated by if user is externally authenticated", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
- &authntest.FakeService{}, nil,
+ &authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
_, gotClaims, err := s.SignIdentity(context.Background(), &authn.Identity{
ID: "1",
@@ -106,7 +107,7 @@ func TestService_SignIdentity(t *testing.T) {
t.Run("should sign new token if org role has changed", func(t *testing.T) {
s := ProvideService(
setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(),
- &authntest.FakeService{}, nil,
+ &authntest.FakeService{}, nil, tracing.InitializeTracerForTest(),
)
ident := &authn.Identity{
diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go
index 7eadac99083..ce0ed9a9e89 100644
--- a/pkg/services/authn/authnimpl/registration.go
+++ b/pkg/services/authn/authnimpl/registration.go
@@ -48,10 +48,10 @@ func ProvideRegistration(
logger := log.New("authn.registration")
authnSvc.RegisterClient(clients.ProvideRender(renderService))
- authnSvc.RegisterClient(clients.ProvideAPIKey(apikeyService))
+ authnSvc.RegisterClient(clients.ProvideAPIKey(apikeyService, tracer))
if cfg.LoginCookieName != "" {
- authnSvc.RegisterClient(clients.ProvideSession(cfg, sessionService, authInfoService))
+ authnSvc.RegisterClient(clients.ProvideSession(cfg, sessionService, authInfoService, tracer))
}
var proxyClients []authn.ProxyClient
@@ -59,20 +59,20 @@ func ProvideRegistration(
// always register LDAP if LDAP is enabled in SSO settings
if cfg.LDAPAuthEnabled || features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsLDAP) {
- ldap := clients.ProvideLDAP(cfg, ldapService, userService, authInfoService)
+ ldap := clients.ProvideLDAP(cfg, ldapService, userService, authInfoService, tracer)
proxyClients = append(proxyClients, ldap)
passwordClients = append(passwordClients, ldap)
}
if !cfg.DisableLogin {
- grafana := clients.ProvideGrafana(cfg, userService)
+ grafana := clients.ProvideGrafana(cfg, userService, tracer)
proxyClients = append(proxyClients, grafana)
passwordClients = append(passwordClients, grafana)
}
// if we have password clients configure check if basic auth or form auth is enabled
if len(passwordClients) > 0 {
- passwordClient := clients.ProvidePassword(loginAttempts, passwordClients...)
+ passwordClient := clients.ProvidePassword(loginAttempts, tracer, passwordClients...)
if cfg.BasicAuthEnabled {
authnSvc.RegisterClient(clients.ProvideBasic(passwordClient))
}
@@ -103,7 +103,7 @@ func ProvideRegistration(
}
if cfg.AuthProxy.Enabled && len(proxyClients) > 0 {
- proxy, err := clients.ProvideProxy(cfg, cache, proxyClients...)
+ proxy, err := clients.ProvideProxy(cfg, cache, tracer, proxyClients...)
if err != nil {
logger.Error("Failed to configure auth proxy", "err", err)
} else {
@@ -113,16 +113,16 @@ func ProvideRegistration(
if cfg.JWTAuth.Enabled {
orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService)
- authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg))
+ authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg, tracer))
}
if cfg.ExtJWTAuth.Enabled {
- authnSvc.RegisterClient(clients.ProvideExtendedJWT(cfg))
+ authnSvc.RegisterClient(clients.ProvideExtendedJWT(cfg, tracer))
}
for name := range socialService.GetOAuthProviders() {
clientName := authn.ClientWithPrefix(name)
- authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features))
+ authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features, tracer))
}
if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
diff --git a/pkg/services/authn/clients/api_key.go b/pkg/services/authn/clients/api_key.go
index 5bc4aa8fb46..2023cb4e418 100644
--- a/pkg/services/authn/clients/api_key.go
+++ b/pkg/services/authn/clients/api_key.go
@@ -7,6 +7,8 @@ import (
"strings"
"time"
+ "go.opentelemetry.io/otel/trace"
+
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/components/apikeygen"
@@ -35,16 +37,18 @@ const (
metaKeySkipLastUsed = "keySkipLastUsed"
)
-func ProvideAPIKey(apiKeyService apikey.Service) *APIKey {
+func ProvideAPIKey(apiKeyService apikey.Service, tracer trace.Tracer) *APIKey {
return &APIKey{
log: log.New(authn.ClientAPIKey),
apiKeyService: apiKeyService,
+ tracer: tracer,
}
}
type APIKey struct {
log log.Logger
apiKeyService apikey.Service
+ tracer trace.Tracer
}
func (s *APIKey) Name() string {
@@ -52,6 +56,8 @@ func (s *APIKey) Name() string {
}
func (s *APIKey) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.apikey.Authenticate")
+ defer span.End()
key, err := s.getAPIKey(ctx, getTokenFromRequest(r))
if err != nil {
if errors.Is(err, apikeygen.ErrInvalidApiKey) {
@@ -84,6 +90,8 @@ func (s *APIKey) IsEnabled() bool {
}
func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.apikey.getAPIKey")
+ defer span.End()
fn := s.getFromToken
if !strings.HasPrefix(token, satokengen.GrafanaPrefix) {
fn = s.getFromTokenLegacy
@@ -98,6 +106,8 @@ func (s *APIKey) getAPIKey(ctx context.Context, token string) (*apikey.APIKey, e
}
func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.apikey.getFromToken")
+ defer span.End()
decoded, err := satokengen.Decode(token)
if err != nil {
return nil, err
@@ -112,6 +122,8 @@ func (s *APIKey) getFromToken(ctx context.Context, token string) (*apikey.APIKey
}
func (s *APIKey) getFromTokenLegacy(ctx context.Context, token string) (*apikey.APIKey, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.apikey.getFromTokenLegacy")
+ defer span.End()
decoded, err := apikeygen.Decode(token)
if err != nil {
return nil, err
@@ -144,6 +156,9 @@ func (s *APIKey) Priority() uint {
}
func (s *APIKey) Hook(ctx context.Context, identity *authn.Identity, r *authn.Request) error {
+ ctx, span := s.tracer.Start(ctx, "authn.apikey.Hook") //nolint:ineffassign,staticcheck
+ defer span.End()
+
if r.GetMeta(metaKeySkipLastUsed) != "" {
return nil
}
diff --git a/pkg/services/authn/clients/api_key_test.go b/pkg/services/authn/clients/api_key_test.go
index 889f38cd4d0..62fc83bc7f0 100644
--- a/pkg/services/authn/clients/api_key_test.go
+++ b/pkg/services/authn/clients/api_key_test.go
@@ -12,6 +12,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/components/satokengen"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/apikey/apikeytest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -106,7 +107,7 @@ func TestAPIKey_Authenticate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
- c := ProvideAPIKey(&apikeytest.Service{ExpectedAPIKey: tt.expectedKey})
+ c := ProvideAPIKey(&apikeytest.Service{ExpectedAPIKey: tt.expectedKey}, tracing.InitializeTracerForTest())
identity, err := c.Authenticate(context.Background(), tt.req)
if tt.expectedErr != nil {
@@ -173,7 +174,7 @@ func TestAPIKey_Test(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
- c := ProvideAPIKey(&apikeytest.Service{})
+ c := ProvideAPIKey(&apikeytest.Service{}, tracing.InitializeTracerForTest())
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
})
}
diff --git a/pkg/services/authn/clients/ext_jwt.go b/pkg/services/authn/clients/ext_jwt.go
index 74754d85681..5baafdf0c6b 100644
--- a/pkg/services/authn/clients/ext_jwt.go
+++ b/pkg/services/authn/clients/ext_jwt.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/go-jose/go-jose/v3/jwt"
+ "go.opentelemetry.io/otel/trace"
authlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
@@ -41,7 +42,7 @@ var (
)
)
-func ProvideExtendedJWT(cfg *setting.Cfg) *ExtendedJWT {
+func ProvideExtendedJWT(cfg *setting.Cfg, tracer trace.Tracer) *ExtendedJWT {
keys := authlib.NewKeyRetriever(authlib.KeyRetrieverConfig{
SigningKeysURL: cfg.ExtJWTAuth.JWKSUrl,
})
@@ -60,6 +61,7 @@ func ProvideExtendedJWT(cfg *setting.Cfg) *ExtendedJWT {
namespaceMapper: request.GetNamespaceMapper(cfg),
accessTokenVerifier: accessTokenVerifier,
idTokenVerifier: idTokenVerifier,
+ tracer: tracer,
}
}
@@ -69,9 +71,13 @@ type ExtendedJWT struct {
accessTokenVerifier authlib.Verifier[authlib.AccessTokenClaims]
idTokenVerifier authlib.Verifier[authlib.IDTokenClaims]
namespaceMapper request.NamespaceMapper
+ tracer trace.Tracer
}
func (s *ExtendedJWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.extjwt.Authenticate")
+ defer span.End()
+
jwtToken := s.retrieveAuthenticationToken(r.HTTPRequest)
accessTokenClaims, err := s.accessTokenVerifier.Verify(ctx, jwtToken)
diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go
index 13970e681be..b72b6303154 100644
--- a/pkg/services/authn/clients/ext_jwt_test.go
+++ b/pkg/services/authn/clients/ext_jwt_test.go
@@ -17,6 +17,7 @@ import (
authnlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/setting"
)
@@ -699,7 +700,7 @@ func setupTestCtx(cfg *setting.Cfg) *testEnv {
}
}
- extJwtClient := ProvideExtendedJWT(cfg)
+ extJwtClient := ProvideExtendedJWT(cfg, tracing.InitializeTracerForTest())
return &testEnv{
s: extJwtClient,
diff --git a/pkg/services/authn/clients/grafana.go b/pkg/services/authn/clients/grafana.go
index ddabb405443..379c19ffca3 100644
--- a/pkg/services/authn/clients/grafana.go
+++ b/pkg/services/authn/clients/grafana.go
@@ -7,6 +7,8 @@ import (
"net/mail"
"strconv"
+ "go.opentelemetry.io/otel/trace"
+
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
@@ -19,13 +21,14 @@ import (
var _ authn.ProxyClient = new(Grafana)
var _ authn.PasswordClient = new(Grafana)
-func ProvideGrafana(cfg *setting.Cfg, userService user.Service) *Grafana {
- return &Grafana{cfg, userService}
+func ProvideGrafana(cfg *setting.Cfg, userService user.Service, tracer trace.Tracer) *Grafana {
+ return &Grafana{cfg, userService, tracer}
}
type Grafana struct {
cfg *setting.Cfg
userService user.Service
+ tracer trace.Tracer
}
func (c *Grafana) String() string {
@@ -33,6 +36,9 @@ func (c *Grafana) String() string {
}
func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.grafana.AuthenticateProxy") //nolint:ineffassign,staticcheck
+ defer span.End()
+
identity := &authn.Identity{
AuthenticatedBy: login.AuthProxyAuthModule,
AuthID: username,
@@ -91,6 +97,9 @@ func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, usern
}
func (c *Grafana) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.grafana.AuthenticatePassword")
+ defer span.End()
+
usr, err := c.userService.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: username})
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
diff --git a/pkg/services/authn/clients/grafana_test.go b/pkg/services/authn/clients/grafana_test.go
index fd93cf2faa7..fd40757e319 100644
--- a/pkg/services/authn/clients/grafana_test.go
+++ b/pkg/services/authn/clients/grafana_test.go
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
claims "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/org"
@@ -97,7 +98,7 @@ func TestGrafana_AuthenticateProxy(t *testing.T) {
cfg := setting.NewCfg()
cfg.AuthProxy.AutoSignUp = true
cfg.AuthProxy.HeaderProperty = tt.proxyProperty
- c := ProvideGrafana(cfg, usertest.NewUserServiceFake())
+ c := ProvideGrafana(cfg, usertest.NewUserServiceFake(), tracing.InitializeTracerForTest())
identity, err := c.AuthenticateProxy(context.Background(), tt.req, tt.username, tt.additional)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -175,7 +176,7 @@ func TestGrafana_AuthenticatePassword(t *testing.T) {
userService.ExpectedError = user.ErrUserNotFound
}
- c := ProvideGrafana(setting.NewCfg(), userService)
+ c := ProvideGrafana(setting.NewCfg(), userService, tracing.InitializeTracerForTest())
identity, err := c.AuthenticatePassword(context.Background(), &authn.Request{OrgID: 1}, tt.username, tt.password)
assert.ErrorIs(t, err, tt.expectedErr)
assert.EqualValues(t, tt.expectedIdentity, identity)
diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go
index 93036b87915..60d7ed21406 100644
--- a/pkg/services/authn/clients/jwt.go
+++ b/pkg/services/authn/clients/jwt.go
@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
+ "go.opentelemetry.io/otel/trace"
+
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social/connectors"
@@ -30,13 +32,14 @@ var (
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim"))
)
-func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg) *JWT {
+func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg, tracer trace.Tracer) *JWT {
return &JWT{
cfg: cfg,
log: log.New(authn.ClientJWT),
jwtService: jwtService,
orgRoleMapper: orgRoleMapper,
orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), cfg.JWTAuth.OrgMapping, cfg.JWTAuth.RoleAttributeStrict),
+ tracer: tracer,
}
}
@@ -46,6 +49,7 @@ type JWT struct {
orgMappingCfg connectors.MappingConfiguration
log log.Logger
jwtService auth.JWTVerifierService
+ tracer trace.Tracer
}
func (s *JWT) Name() string {
@@ -53,6 +57,9 @@ func (s *JWT) Name() string {
}
func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := s.tracer.Start(ctx, "authn.jwt.Authenticate")
+ defer span.End()
+
jwtToken := s.retrieveToken(r.HTTPRequest)
s.stripSensitiveParam(r.HTTPRequest)
diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go
index 7fccffc6b17..2de491935b7 100644
--- a/pkg/services/authn/clients/jwt_test.go
+++ b/pkg/services/authn/clients/jwt_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/authn"
@@ -262,7 +263,7 @@ func TestAuthenticateJWT(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(tc.cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
- tc.cfg)
+ tc.cfg, tracing.InitializeTracerForTest())
validHTTPReq := &http.Request{
Header: map[string][]string{
jwtHeaderName: {"sample-token"}},
@@ -380,7 +381,7 @@ func TestJWTClaimConfig(t *testing.T) {
}
jwtClient := ProvideJWT(jwtService, connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
- cfg)
+ cfg, tracing.InitializeTracerForTest())
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -493,7 +494,7 @@ func TestJWTTest(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
- cfg)
+ cfg, tracing.InitializeTracerForTest())
httpReq := &http.Request{
URL: &url.URL{RawQuery: "auth_token=" + tc.token},
Header: map[string][]string{
@@ -549,7 +550,7 @@ func TestJWTStripParam(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
- cfg)
+ cfg, tracing.InitializeTracerForTest())
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -608,7 +609,7 @@ func TestJWTSubClaimsConfig(t *testing.T) {
jwtClient := ProvideJWT(jwtService,
connectors.ProvideOrgRoleMapper(cfg,
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
- cfg)
+ cfg, tracing.InitializeTracerForTest())
identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
diff --git a/pkg/services/authn/clients/ldap.go b/pkg/services/authn/clients/ldap.go
index e78f0ff119c..4f9cbccf066 100644
--- a/pkg/services/authn/clients/ldap.go
+++ b/pkg/services/authn/clients/ldap.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
+ "go.opentelemetry.io/otel/trace"
+
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
@@ -20,8 +22,8 @@ type ldapService interface {
User(username string) (*login.ExternalUserInfo, error)
}
-func ProvideLDAP(cfg *setting.Cfg, ldapService ldapService, userService user.Service, authInfoService login.AuthInfoService) *LDAP {
- return &LDAP{cfg, log.New("authn.ldap"), ldapService, userService, authInfoService}
+func ProvideLDAP(cfg *setting.Cfg, ldapService ldapService, userService user.Service, authInfoService login.AuthInfoService, tracer trace.Tracer) *LDAP {
+ return &LDAP{cfg, log.New("authn.ldap"), ldapService, userService, authInfoService, tracer}
}
type LDAP struct {
@@ -30,6 +32,7 @@ type LDAP struct {
service ldapService
userService user.Service
authInfoService login.AuthInfoService
+ tracer trace.Tracer
}
func (c *LDAP) String() string {
@@ -37,6 +40,8 @@ func (c *LDAP) String() string {
}
func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, _ map[string]string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.ldap.AuthenticateProxy")
+ defer span.End()
info, err := c.service.User(username)
if errors.Is(err, multildap.ErrDidNotFindUser) {
return c.disableUser(ctx, username)
@@ -50,6 +55,8 @@ func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username
}
func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.ldap.AuthenticatePassword")
+ defer span.End()
info, err := c.service.Login(&login.LoginUserQuery{
Username: username,
Password: password,
@@ -75,6 +82,8 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern
// disableUser will disable users if they logged in via LDAP previously
func (c *LDAP) disableUser(ctx context.Context, username string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.ldap.disableUser")
+ defer span.End()
c.logger.Debug("User was not found in the LDAP directory tree", "username", username)
retErr := errIdentityNotFound.Errorf("no user found: %w", multildap.ErrDidNotFindUser)
diff --git a/pkg/services/authn/clients/ldap_test.go b/pkg/services/authn/clients/ldap_test.go
index cd08fa2ac01..2d09ef5c933 100644
--- a/pkg/services/authn/clients/ldap_test.go
+++ b/pkg/services/authn/clients/ldap_test.go
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
- "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/ldap/multildap"
@@ -197,13 +197,13 @@ func setupLDAPTestCase(tt *ldapTestCase) *LDAP {
ExpectedError: tt.expectedAuthInfoErr,
}
- c := &LDAP{
- cfg: setting.NewCfg(),
- logger: log.New("authn.ldap.test"),
- service: &service.LDAPFakeService{ExpectedUser: tt.expectedLDAPInfo, ExpectedError: tt.expectedLDAPErr},
- userService: userService,
- authInfoService: authInfoService,
- }
+ c := ProvideLDAP(
+ setting.NewCfg(),
+ &service.LDAPFakeService{ExpectedUser: tt.expectedLDAPInfo, ExpectedError: tt.expectedLDAPErr},
+ userService,
+ authInfoService,
+ tracing.InitializeTracerForTest(),
+ )
return c
}
diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go
index 1c6429c3cc4..0e8053a39b7 100644
--- a/pkg/services/authn/clients/oauth.go
+++ b/pkg/services/authn/clients/oauth.go
@@ -12,6 +12,7 @@ import (
"os"
"strings"
+ "go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
@@ -70,12 +71,14 @@ var (
func ProvideOAuth(
name string, cfg *setting.Cfg, oauthService oauthtoken.OAuthTokenService,
- socialService social.Service, settingsProviderService setting.Provider, features featuremgmt.FeatureToggles,
+ socialService social.Service, settingsProviderService setting.Provider,
+ features featuremgmt.FeatureToggles, tracer trace.Tracer,
) *OAuth {
providerName := strings.TrimPrefix(name, "auth.client.")
return &OAuth{
name, fmt.Sprintf("oauth_%s", providerName), providerName,
- log.New(name), cfg, settingsProviderService, oauthService, socialService, features,
+ log.New(name), cfg, tracer, settingsProviderService, oauthService,
+ socialService, features,
}
}
@@ -85,6 +88,7 @@ type OAuth struct {
providerName string
log log.Logger
cfg *setting.Cfg
+ tracer trace.Tracer
settingsProviderSvc setting.Provider
oauthService oauthtoken.OAuthTokenService
@@ -97,6 +101,9 @@ func (c *OAuth) Name() string {
}
func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.oauth.Authenticate")
+ defer span.End()
+
r.SetMeta(authn.MetaKeyAuthModule, c.moduleName)
oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName)
@@ -232,6 +239,9 @@ func (c *OAuth) GetConfig() authn.SSOClientConfig {
}
func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redirect, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.oauth.RedirectURL") //nolint:ineffassign,staticcheck
+ defer span.End()
+
var opts []oauth2.AuthCodeOption
oauthCfg := c.socialService.GetOAuthInfoProvider(c.providerName)
@@ -274,6 +284,9 @@ func (c *OAuth) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redir
}
func (c *OAuth) Logout(ctx context.Context, user identity.Requester, sessionToken *auth.UserToken) (*authn.Redirect, bool) {
+ ctx, span := c.tracer.Start(ctx, "authn.oauth.Logout")
+ defer span.End()
+
token := c.oauthService.GetCurrentOAuthToken(ctx, user, sessionToken)
userID, err := identity.UserIdentifier(user.GetID())
diff --git a/pkg/services/authn/clients/oauth_test.go b/pkg/services/authn/clients/oauth_test.go
index 7a9222154f7..2bd536d6544 100644
--- a/pkg/services/authn/clients/oauth_test.go
+++ b/pkg/services/authn/clients/oauth_test.go
@@ -16,6 +16,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/services/auth"
@@ -296,7 +297,7 @@ func TestOAuth_Authenticate(t *testing.T) {
},
}
- c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, settingsProvider, featuremgmt.WithFeatures(tt.features...))
+ c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, settingsProvider, featuremgmt.WithFeatures(tt.features...), tracing.InitializeTracerForTest())
identity, err := c.Authenticate(context.Background(), tt.req)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -376,7 +377,7 @@ func TestOAuth_RedirectURL(t *testing.T) {
cfg := setting.NewCfg()
- c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, &setting.OSSImpl{Cfg: cfg}, featuremgmt.WithFeatures())
+ c := ProvideOAuth(authn.ClientWithPrefix("azuread"), cfg, nil, fakeSocialSvc, &setting.OSSImpl{Cfg: cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest())
redirect, err := c.RedirectURL(context.Background(), nil)
assert.ErrorIs(t, err, tt.expectedErr)
@@ -489,7 +490,7 @@ func TestOAuth_Logout(t *testing.T) {
fakeSocialSvc := &socialtest.FakeSocialService{
ExpectedAuthInfoProvider: tt.oauthCfg,
}
- c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures())
+ c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest())
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, nil)
@@ -549,7 +550,8 @@ func TestIsEnabled(t *testing.T) {
nil,
fakeSocialSvc,
&setting.OSSImpl{Cfg: cfg},
- featuremgmt.WithFeatures())
+ featuremgmt.WithFeatures(),
+ tracing.InitializeTracerForTest())
assert.Equal(t, tt.expected, c.IsEnabled())
})
}
diff --git a/pkg/services/authn/clients/password.go b/pkg/services/authn/clients/password.go
index 76e82d4fc59..f9b2483d2c5 100644
--- a/pkg/services/authn/clients/password.go
+++ b/pkg/services/authn/clients/password.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
+ "go.opentelemetry.io/otel/trace"
+
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn"
@@ -18,17 +20,20 @@ var (
var _ authn.PasswordClient = new(Password)
-func ProvidePassword(loginAttempts loginattempt.Service, clients ...authn.PasswordClient) *Password {
- return &Password{loginAttempts, clients, log.New("authn.password")}
+func ProvidePassword(loginAttempts loginattempt.Service, tracer trace.Tracer, clients ...authn.PasswordClient) *Password {
+ return &Password{loginAttempts, clients, log.New("authn.password"), tracer}
}
type Password struct {
loginAttempts loginattempt.Service
clients []authn.PasswordClient
log log.Logger
+ tracer trace.Tracer
}
func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.password.AuthenticatePassword")
+ defer span.End()
r.SetMeta(authn.MetaKeyUsername, username)
ok, err := c.loginAttempts.Validate(ctx, username)
diff --git a/pkg/services/authn/clients/password_test.go b/pkg/services/authn/clients/password_test.go
index 6f7619ce1b7..fd7933a37e7 100644
--- a/pkg/services/authn/clients/password_test.go
+++ b/pkg/services/authn/clients/password_test.go
@@ -10,6 +10,7 @@ import (
claims "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest"
@@ -65,7 +66,7 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
- c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...)
+ c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tracing.InitializeTracerForTest(), tt.clients...)
r := &authn.Request{
OrgID: 12345,
HTTPRequest: &http.Request{
diff --git a/pkg/services/authn/clients/proxy.go b/pkg/services/authn/clients/proxy.go
index 237d02f13cc..f20b1534a02 100644
--- a/pkg/services/authn/clients/proxy.go
+++ b/pkg/services/authn/clients/proxy.go
@@ -13,6 +13,7 @@ import (
"time"
claims "github.com/grafana/authlib/types"
+ "go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
@@ -45,12 +46,12 @@ var (
_ authn.ContextAwareClient = new(Proxy)
)
-func ProvideProxy(cfg *setting.Cfg, cache proxyCache, clients ...authn.ProxyClient) (*Proxy, error) {
+func ProvideProxy(cfg *setting.Cfg, cache proxyCache, tracer trace.Tracer, clients ...authn.ProxyClient) (*Proxy, error) {
list, err := parseAcceptList(cfg.AuthProxy.Whitelist)
if err != nil {
return nil, err
}
- return &Proxy{log.New(authn.ClientProxy), cfg, cache, clients, list}, nil
+ return &Proxy{log.New(authn.ClientProxy), cfg, cache, clients, list, tracer}, nil
}
type proxyCache interface {
@@ -65,6 +66,7 @@ type Proxy struct {
cache proxyCache
clients []authn.ProxyClient
acceptedIPs []*net.IPNet
+ tracer trace.Tracer
}
func (c *Proxy) Name() string {
@@ -72,6 +74,8 @@ func (c *Proxy) Name() string {
}
func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.proxy.Authenticate")
+ defer span.End()
if !c.isAllowedIP(r) {
return nil, errNotAcceptedIP.Errorf("request ip is not in the configured accept list")
}
@@ -115,6 +119,8 @@ func (c *Proxy) IsEnabled() bool {
// See if we have cached the user id, in that case we can fetch the signed-in user and skip sync.
// Error here means that we could not find anything in cache, so we can proceed as usual
func (c *Proxy) retrieveIDFromCache(ctx context.Context, cacheKey string, r *authn.Request) (*authn.Identity, error) {
+ ctx, span := c.tracer.Start(ctx, "authn.proxy.retrieveIDFromCache")
+ defer span.End()
entry, err := c.cache.Get(ctx, cacheKey)
if err != nil {
return nil, err
@@ -148,6 +154,8 @@ func (c *Proxy) Priority() uint {
}
func (c *Proxy) Hook(ctx context.Context, id *authn.Identity, r *authn.Request) error {
+ ctx, span := c.tracer.Start(ctx, "authn.proxy.Hook")
+ defer span.End()
if id.ClientParams.CacheAuthProxyKey == "" {
return nil
}
diff --git a/pkg/services/authn/clients/proxy_test.go b/pkg/services/authn/clients/proxy_test.go
index b0cefbd6d6c..7b1faaa9efa 100644
--- a/pkg/services/authn/clients/proxy_test.go
+++ b/pkg/services/authn/clients/proxy_test.go
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/setting"
@@ -113,7 +114,7 @@ func TestProxy_Authenticate(t *testing.T) {
calledAdditional = additional
return nil, nil
}}
- c, err := ProvideProxy(cfg, &fakeCache{expectedErr: errors.New("")}, proxyClient)
+ c, err := ProvideProxy(cfg, &fakeCache{expectedErr: errors.New("")}, tracing.InitializeTracerForTest(), proxyClient)
require.NoError(t, err)
_, err = c.Authenticate(context.Background(), tt.req)
@@ -169,7 +170,7 @@ func TestProxy_Test(t *testing.T) {
cfg := setting.NewCfg()
cfg.AuthProxy.HeaderName = "Proxy-Header"
- c, _ := ProvideProxy(cfg, nil, nil, nil)
+ c, _ := ProvideProxy(cfg, nil, tracing.InitializeTracerForTest(), nil)
assert.Equal(t, tt.expectedOK, c.Test(context.Background(), tt.req))
})
}
@@ -208,7 +209,7 @@ func TestProxy_Hook(t *testing.T) {
withRole := func(role string) func(t *testing.T) {
cacheKey := fmt.Sprintf("users:johndoe-%s", role)
return func(t *testing.T) {
- c, err := ProvideProxy(cfg, cache, authntest.MockProxyClient{})
+ c, err := ProvideProxy(cfg, cache, tracing.InitializeTracerForTest(), authntest.MockProxyClient{})
require.NoError(t, err)
userIdentity := &authn.Identity{
ID: "1",
diff --git a/pkg/services/authn/clients/session.go b/pkg/services/authn/clients/session.go
index fa0f60b8e12..a644c4771af 100644
--- a/pkg/services/authn/clients/session.go
+++ b/pkg/services/authn/clients/session.go
@@ -7,6 +7,8 @@ import (
"strconv"
"time"
+ "go.opentelemetry.io/otel/trace"
+
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth"
@@ -18,12 +20,14 @@ import (
var _ authn.ContextAwareClient = new(Session)
-func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService, authInfoService login.AuthInfoService) *Session {
+func ProvideSession(cfg *setting.Cfg, sessionService auth.UserTokenService,
+ authInfoService login.AuthInfoService, tracer trace.Tracer) *Session {
return &Session{
cfg: cfg,
log: log.New(authn.ClientSession),
sessionService: sessionService,
authInfoService: authInfoService,
+ tracer: tracer,
}
}
@@ -32,6 +36,7 @@ type Session struct {
log log.Logger
sessionService auth.UserTokenService
authInfoService login.AuthInfoService
+ tracer trace.Tracer
}
func (s *Session) Name() string {
diff --git a/pkg/services/authn/clients/session_test.go b/pkg/services/authn/clients/session_test.go
index 946168f9f14..13195ba2b9b 100644
--- a/pkg/services/authn/clients/session_test.go
+++ b/pkg/services/authn/clients/session_test.go
@@ -11,6 +11,7 @@ import (
claims "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
@@ -31,7 +32,7 @@ func TestSession_Test(t *testing.T) {
cfg := setting.NewCfg()
cfg.LoginCookieName = ""
cfg.LoginMaxLifetime = 20 * time.Second
- s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, &authinfotest.FakeService{})
+ s := ProvideSession(cfg, &authtest.FakeUserAuthTokenService{}, &authinfotest.FakeService{}, tracing.InitializeTracerForTest())
disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
assert.False(t, disabled)
@@ -194,7 +195,7 @@ func TestSession_Authenticate(t *testing.T) {
cfg.LoginCookieName = cookieName
cfg.TokenRotationIntervalMinutes = 10
cfg.LoginMaxLifetime = 20 * time.Second
- s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.authInfoService)
+ s := ProvideSession(cfg, tt.fields.sessionService, tt.fields.authInfoService, tracing.InitializeTracerForTest())
got, err := s.Authenticate(context.Background(), tt.args.r)
require.True(t, (err != nil) == tt.wantErr, err)
From 441f56f6cef5caefdfb6acf11893d80ba09a54c8 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 13:59:51 +0000
Subject: [PATCH 27/33] fix(deps): update dependency micro-memoize to v4.1.3
(#107966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
yarn.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 3f99cdeb046..913b64a68a1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -22640,9 +22640,9 @@ __metadata:
linkType: hard
"micro-memoize@npm:^4.1.2":
- version: 4.1.2
- resolution: "micro-memoize@npm:4.1.2"
- checksum: 10/027e90c3147c97c07224440ea50ede27eb7d888149e4925820397b466d16efc525f5ec3981e4cadec3258a8d36dfd5e7e7c8e660879fbe2e47106785be9bc570
+ version: 4.1.3
+ resolution: "micro-memoize@npm:4.1.3"
+ checksum: 10/4e9c7767911cc76ae9c9779584ec87844437af9446b295a01774640a732c2c7f91944794027f44625031f7330ab7f9147740d0a9fb612680d1d2d858dad43402
languageName: node
linkType: hard
From ac7a411c539432036954ee14477ab967e6bdbc65 Mon Sep 17 00:00:00 2001
From: colin-stuart
Date: Thu, 10 Jul 2025 09:18:30 -0500
Subject: [PATCH 28/33] SCIM: Update allow non-provisioned users dynamic config
field (#107912)
SCIM: add dynamic non-provisioned users allowed setting
---
pkg/services/scimutil/scim_util.go | 14 +++++---------
pkg/services/scimutil/scim_util_test.go | 9 ++++-----
2 files changed, 9 insertions(+), 14 deletions(-)
diff --git a/pkg/services/scimutil/scim_util.go b/pkg/services/scimutil/scim_util.go
index 022277ba994..92d0ac74270 100644
--- a/pkg/services/scimutil/scim_util.go
+++ b/pkg/services/scimutil/scim_util.go
@@ -83,11 +83,7 @@ func (s *SCIMUtil) fetchDynamicSCIMSetting(ctx context.Context, orgID int64, set
case "group":
enabled = scimConfig.EnableGroupSync
case "allowNonProvisionedUsers":
- if scimConfig.AllowNonProvisionedUsers != nil {
- enabled = *scimConfig.AllowNonProvisionedUsers
- } else {
- enabled = false
- }
+ enabled = scimConfig.AllowNonProvisionedUsers
default:
s.logger.Error("Invalid setting type provided to fetchDynamicSCIMSetting", "settingType", settingType)
return false, false
@@ -112,9 +108,9 @@ func (s *SCIMUtil) getOrgSCIMConfig(ctx context.Context, orgID int64) (*SCIMConf
// SCIMConfigSpec represents the spec part of a SCIMConfig resource
type SCIMConfigSpec struct {
- EnableUserSync bool `json:"enableUserSync"`
- EnableGroupSync bool `json:"enableGroupSync"`
- AllowNonProvisionedUsers *bool `json:"allowNonProvisionedUsers,omitempty"`
+ EnableUserSync bool `json:"enableUserSync"`
+ EnableGroupSync bool `json:"enableGroupSync"`
+ AllowNonProvisionedUsers bool `json:"allowNonProvisionedUsers"`
}
// unstructuredToSCIMConfig converts an unstructured object to a SCIMConfigSpec
@@ -139,6 +135,6 @@ func (s *SCIMUtil) unstructuredToSCIMConfig(obj *unstructured.Unstructured) (*SC
return &SCIMConfigSpec{
EnableUserSync: enableUserSync,
EnableGroupSync: enableGroupSync,
- AllowNonProvisionedUsers: &allowNonProvisionedUsers,
+ AllowNonProvisionedUsers: allowNonProvisionedUsers,
}, nil
}
diff --git a/pkg/services/scimutil/scim_util_test.go b/pkg/services/scimutil/scim_util_test.go
index 2e371d32fd8..0e9d3b39ee5 100644
--- a/pkg/services/scimutil/scim_util_test.go
+++ b/pkg/services/scimutil/scim_util_test.go
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
- "github.com/grafana/grafana/pkg/util"
)
// MockK8sHandler is a mock implementation of client.K8sHandler for testing
@@ -573,7 +572,7 @@ func TestSCIMUtil_unstructuredToSCIMConfig(t *testing.T) {
expectedSpec: SCIMConfigSpec{
EnableUserSync: true,
EnableGroupSync: true,
- AllowNonProvisionedUsers: util.Pointer(false),
+ AllowNonProvisionedUsers: false,
},
},
{
@@ -582,7 +581,7 @@ func TestSCIMUtil_unstructuredToSCIMConfig(t *testing.T) {
expectedSpec: SCIMConfigSpec{
EnableUserSync: false,
EnableGroupSync: false,
- AllowNonProvisionedUsers: util.Pointer(false),
+ AllowNonProvisionedUsers: false,
},
},
{
@@ -591,7 +590,7 @@ func TestSCIMUtil_unstructuredToSCIMConfig(t *testing.T) {
expectedSpec: SCIMConfigSpec{
EnableUserSync: true,
EnableGroupSync: false,
- AllowNonProvisionedUsers: util.Pointer(false),
+ AllowNonProvisionedUsers: false,
},
},
{
@@ -600,7 +599,7 @@ func TestSCIMUtil_unstructuredToSCIMConfig(t *testing.T) {
expectedSpec: SCIMConfigSpec{
EnableUserSync: false,
EnableGroupSync: false,
- AllowNonProvisionedUsers: util.Pointer(true),
+ AllowNonProvisionedUsers: true,
},
},
{
From 84ef5bc744c01b864c9daa8d89ce9c30cbd381b2 Mon Sep 17 00:00:00 2001
From: Gareth
Date: Thu, 10 Jul 2025 15:54:16 +0100
Subject: [PATCH 29/33] Remove jaegerBackendMigration feature toggle (#107702)
* remove feature toggle if statements
* remove unused impoerts
* remove unused private functions
* prettier
* official ft removal
* fix some failing tests in datasource.test.ts
* clean up test file
* update test names
* remove tests for testDatasource
* remove describe
* tests
* fix import order
* betterer
---
.betterer.results | 3 +-
.../feature-toggles/index.md | 1 -
.../src/types/featureToggles.gen.ts | 5 -
pkg/services/featuremgmt/registry.go | 7 -
pkg/services/featuremgmt/toggles_gen.csv | 1 -
pkg/services/featuremgmt/toggles_gen.go | 4 -
pkg/services/featuremgmt/toggles_gen.json | 1 +
.../datasource/jaeger/datasource.test.ts | 532 +++---------------
.../plugins/datasource/jaeger/datasource.ts | 179 +-----
.../datasource/jaeger/mockSearchResponse.json | 52 ++
.../datasource/jaeger/mockTraceResponse.json | 21 +
11 files changed, 174 insertions(+), 632 deletions(-)
create mode 100644 public/app/plugins/datasource/jaeger/mockSearchResponse.json
create mode 100644 public/app/plugins/datasource/jaeger/mockTraceResponse.json
diff --git a/.betterer.results b/.betterer.results
index cfb0ebda934..8a98e82ff57 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -3462,8 +3462,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not re-export imported variable (\`./trace\`)", "0"]
],
"public/app/plugins/datasource/jaeger/datasource.ts:5381": [
- [0, 0, 0, "Do not use any type assertions.", "0"],
- [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+ [0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/loki/LanguageProvider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index 4015e51df88..060345d124a 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -72,7 +72,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `pluginsSriChecks` | Enables SRI checks for plugin assets | |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
-| `jaegerBackendMigration` | Enables querying the Jaeger data source without the proxy | Yes |
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes |
| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes |
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index c1099124dc5..81468c40d02 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -738,11 +738,6 @@ export interface FeatureToggles {
*/
crashDetection?: boolean;
/**
- * Enables querying the Jaeger data source without the proxy
- * @default true
- */
- jaegerBackendMigration?: boolean;
- /**
* Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query
* @default true
*/
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index c099d584788..7feb6c5dbda 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -1261,13 +1261,6 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: true,
},
- {
- Name: "jaegerBackendMigration",
- Description: "Enables querying the Jaeger data source without the proxy",
- Stage: FeatureStageGeneralAvailability,
- Owner: grafanaOSSBigTent,
- Expression: "true",
- },
{
Name: "alertingUIOptimizeReducer",
Description: "Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query",
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index dffcd8bcad4..0fe72290ac6 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -165,7 +165,6 @@ prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,fal
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
enableSCIM,preview,@grafana/identity-access-team,false,false,false
crashDetection,experimental,@grafana/observability-traces-and-profiling,false,false,true
-jaegerBackendMigration,GA,@grafana/oss-big-tent,false,false,false
alertingUIOptimizeReducer,GA,@grafana/alerting-squad,false,false,true
azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index e105464df6a..981a2ad0db2 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -671,10 +671,6 @@ const (
// Enables browser crash detection reporting to Faro.
FlagCrashDetection = "crashDetection"
- // FlagJaegerBackendMigration
- // Enables querying the Jaeger data source without the proxy
- FlagJaegerBackendMigration = "jaegerBackendMigration"
-
// FlagAlertingUIOptimizeReducer
// Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query
FlagAlertingUIOptimizeReducer = "alertingUIOptimizeReducer"
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index 1735b3c01b0..4cb7929a9c9 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -1542,6 +1542,7 @@
"name": "jaegerBackendMigration",
"resourceVersion": "1751465665226",
"creationTimestamp": "2024-11-15T14:40:20Z",
+ "deletionTimestamp": "2025-07-07T14:12:35Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-07-02 14:14:25.226989 +0000 UTC"
}
diff --git a/public/app/plugins/datasource/jaeger/datasource.test.ts b/public/app/plugins/datasource/jaeger/datasource.test.ts
index 2fa37357816..5c0961d4ac7 100644
--- a/public/app/plugins/datasource/jaeger/datasource.test.ts
+++ b/public/app/plugins/datasource/jaeger/datasource.test.ts
@@ -1,4 +1,4 @@
-import { lastValueFrom, of, throwError } from 'rxjs';
+import { lastValueFrom, of } from 'rxjs';
import {
DataQueryRequest,
@@ -9,18 +9,12 @@ import {
PluginType,
ScopedVars,
} from '@grafana/data';
-import { BackendSrv, config, DataSourceWithBackend } from '@grafana/runtime';
+import { BackendSrv, DataSourceWithBackend } from '@grafana/runtime';
-import { ALL_OPERATIONS_KEY } from './components/SearchForm';
import { JaegerDatasource, JaegerJsonData } from './datasource';
-import { createFetchResponse } from './helpers/createFetchResponse';
import mockJson from './mockJsonResponse.json';
-import {
- testResponse,
- testResponseDataFrameFields,
- testResponseEdgesFields,
- testResponseNodesFields,
-} from './testResponse';
+import mockSearchResponse from './mockSearchResponse.json';
+import mockTraceResponse from './mockTraceResponse.json';
import { JaegerQuery } from './types';
export const backendSrv = { fetch: jest.fn() } as unknown as BackendSrv;
@@ -38,128 +32,29 @@ jest.mock('@grafana/runtime', () => ({
}),
}));
-const defaultQuery: DataQueryRequest = {
- requestId: '1',
- interval: '0',
- intervalMs: 10,
- panelId: 0,
- scopedVars: {},
- range: {
- from: dateTime().subtract(1, 'h'),
- to: dateTime(),
- raw: { from: '1h', to: 'now' },
- },
- timezone: 'browser',
- app: 'explore',
- startTime: 0,
- targets: [
- {
- query: '12345',
- refId: '1',
- },
- ],
-};
-
-describe('JaegerDatasource', () => {
- const defaultSearchRangeParams = `start=${Number(defaultQuery.range.from) * 1000}&end=${Number(defaultQuery.range.to) * 1000}`;
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- const fetchMock = jest.spyOn(Date, 'now');
- fetchMock.mockImplementation(() => 1704106800000); // milliseconds for 2024-01-01 at 11:00am UTC
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- it('returns trace and graph when queried', async () => {
- setupFetchMock({ data: [testResponse] });
-
- const ds = new JaegerDatasource(defaultSettings);
- const response = await lastValueFrom(ds.query(defaultQuery));
- expect(response.data.length).toBe(3);
- expect(response.data[0].fields).toMatchObject(testResponseDataFrameFields);
- expect(response.data[1].fields).toMatchObject(testResponseNodesFields);
- expect(response.data[2].fields).toMatchObject(testResponseEdgesFields);
- });
-
- it('returns trace when traceId with special characters is queried', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- const query = {
- ...defaultQuery,
- targets: [
- {
- query: 'a/b',
- refId: '1',
- },
- ],
- };
- await lastValueFrom(ds.query(query));
- expect(mock).toHaveBeenCalledWith({ url: `${defaultSettings.url}/api/traces/a%2Fb` });
- });
-
- it('should trim whitespace from traceid', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- const query = {
- ...defaultQuery,
- targets: [
- {
- query: 'a/b ',
- refId: '1',
- },
- ],
- };
- await lastValueFrom(ds.query(query));
- expect(mock).toHaveBeenCalledWith({ url: `${defaultSettings.url}/api/traces/a%2Fb` });
- });
-
- it('returns empty response if trace id is not specified', async () => {
- const ds = new JaegerDatasource(defaultSettings);
- const response = await lastValueFrom(
- ds.query({
- ...defaultQuery,
- targets: [],
- })
- );
- const field = response.data[0].fields[0];
- expect(field.name).toBe('trace');
- expect(field.type).toBe(FieldType.trace);
- expect(field.values.length).toBe(0);
- });
-
- it('should handle json file upload', async () => {
+describe('upload, search and trace query types', () => {
+ it('should process valid JSON file uploads', async () => {
const ds = new JaegerDatasource(defaultSettings);
ds.uploadedJson = JSON.stringify(mockJson);
- const response = await lastValueFrom(
- ds.query({
- ...defaultQuery,
- targets: [{ queryType: 'upload', refId: 'A' }],
- })
- );
+ const response = await lastValueFrom(ds.query({ ...defaultQuery, targets: [{ queryType: 'upload', refId: 'A' }] }));
const field = response.data[0].fields[0];
expect(field.name).toBe('traceID');
expect(field.type).toBe(FieldType.string);
expect(field.values.length).toBe(2);
});
- it('should fail on invalid json file upload', async () => {
+ it('should reject invalid JSON file uploads', async () => {
const ds = new JaegerDatasource(defaultSettings);
ds.uploadedJson = JSON.stringify({ key: 'value', arr: [] });
const response = await lastValueFrom(
- ds.query({
- targets: [{ queryType: 'upload', refId: 'A' }],
- } as DataQueryRequest)
+ ds.query({ targets: [{ queryType: 'upload', refId: 'A' }] } as DataQueryRequest)
);
expect(response.error?.message).toBe('The JSON file uploaded is not in a valid Jaeger format');
expect(response.data.length).toBe(0);
});
- it('should return search results when the query type is search', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
+ it('should return search results when query type is search', async () => {
+ setupQueryMock('search');
const ds = new JaegerDatasource(defaultSettings);
const response = await lastValueFrom(
ds.query({
@@ -167,185 +62,66 @@ describe('JaegerDatasource', () => {
targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', operation: '/api/services' }],
})
);
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces?service=jaeger-query&operation=%2Fapi%2Fservices&${defaultSearchRangeParams}&lookback=custom`,
- });
+
expect(response.data[0].meta.preferredVisualisationType).toBe('table');
- // Make sure that traceID field has data link configured
expect(response.data[0].fields[0].config.links).toHaveLength(1);
expect(response.data[0].fields[0].name).toBe('traceID');
});
- it('uses default range when no range is provided for search query,', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
+ it('should return trace results when query type is trace', async () => {
+ setupQueryMock('trace');
const ds = new JaegerDatasource(defaultSettings);
- const query = {
- ...defaultQuery,
- targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', operation: ALL_OPERATIONS_KEY }],
- // set range to undefined to test default range
- range: undefined,
- } as unknown as DataQueryRequest;
+ const response = await lastValueFrom(
+ ds.query({ ...defaultQuery, targets: [{ queryType: undefined, refId: 'a', query: '12345' }] })
+ );
- ds.query(query);
- expect(mock).toHaveBeenCalledWith({
- // Check that query has time range from 6 hours ago to now (default range)
- url: `${defaultSettings.url}/api/traces?service=jaeger-query&start=1704085200000000&end=1704106800000000&lookback=custom`,
- });
+ expect(response.data[0].meta.preferredVisualisationType).toBe('trace');
+ expect(response.data[0].fields.length).toBe(7);
});
+});
+
+describe('node graph functionality', () => {
+ it('should include node graph frames when nodeGraph is enabled for trace queries', async () => {
+ const settingsWithNodeGraph = {
+ ...defaultSettings,
+ jsonData: {
+ ...defaultSettings.jsonData,
+ nodeGraph: { enabled: true },
+ },
+ };
+
+ const ds = new JaegerDatasource(settingsWithNodeGraph);
+ setupQueryMock('trace');
- it('should show the correct error message if no service name is selected', async () => {
- const ds = new JaegerDatasource(defaultSettings);
const response = await lastValueFrom(
ds.query({
...defaultQuery,
- targets: [{ queryType: 'search', refId: 'a', service: undefined, operation: '/api/services' }],
- })
- );
- expect(response.error?.message).toBe('You must select a service.');
- });
-
- it('should remove operation from the query when all is selected', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- await lastValueFrom(
- ds.query({
- ...defaultQuery,
- targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', operation: ALL_OPERATIONS_KEY }],
- })
- );
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces?service=jaeger-query&${defaultSearchRangeParams}&lookback=custom`,
- });
- });
-
- it('should convert tags from logfmt format to an object', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- await lastValueFrom(
- ds.query({
- ...defaultQuery,
- targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', tags: 'error=true' }],
- })
- );
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces?service=jaeger-query&tags=%7B%22error%22%3A%22true%22%7D&${defaultSearchRangeParams}&lookback=custom`,
- });
- });
-
- it('should resolve templates in traceID', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
-
- await lastValueFrom(
- ds.query({
- ...defaultQuery,
- scopedVars: {
- $traceid: {
- text: 'traceid',
- value: '5311b0dd0ca8df3463df93c99cb805a6',
- },
- },
targets: [
{
- query: '$traceid',
+ query: '12345',
refId: '1',
},
],
})
);
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces/5311b0dd0ca8df3463df93c99cb805a6`,
- });
+
+ expect(response.data.length).toBe(3);
});
- it('should resolve templates in tags', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- await lastValueFrom(
+ it('should exclude node graph frames when nodeGraph is disabled for trace queries', async () => {
+ const settingsWithoutNodeGraph = {
+ ...defaultSettings,
+ jsonData: {
+ ...defaultSettings.jsonData,
+ nodeGraph: { enabled: false },
+ },
+ };
+
+ const ds = new JaegerDatasource(settingsWithoutNodeGraph);
+ setupQueryMock('trace');
+
+ const response = await lastValueFrom(
ds.query({
- ...defaultQuery,
- scopedVars: {
- 'error=$error': {
- text: 'error',
- value: 'error=true',
- },
- },
- targets: [{ queryType: 'search', refId: 'a', service: 'jaeger-query', tags: 'error=$error' }],
- })
- );
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces?service=jaeger-query&tags=%7B%22error%22%3A%22true%22%7D&${defaultSearchRangeParams}&lookback=custom`,
- });
- });
-
- it('should interpolate variables correctly', async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- const text = 'interpolationText';
- await lastValueFrom(
- ds.query({
- ...defaultQuery,
- scopedVars: {
- $interpolationVar: {
- text: text,
- value: text,
- },
- },
- targets: [
- {
- queryType: 'search',
- refId: 'a',
- service: '$interpolationVar',
- operation: '$interpolationVar',
- minDuration: '$interpolationVar',
- maxDuration: '$interpolationVar',
- },
- ],
- })
- );
- expect(mock).toHaveBeenCalledWith({
- url: `${defaultSettings.url}/api/traces?service=interpolationText&operation=interpolationText&minDuration=interpolationText&maxDuration=interpolationText&${defaultSearchRangeParams}&lookback=custom`,
- });
- });
-
- describe('when jaegerBackendMigration feature toggle is enabled', () => {
- let originalFeatureToggleValue: boolean | undefined;
-
- beforeEach(() => {
- originalFeatureToggleValue = config.featureToggles.jaegerBackendMigration;
- config.featureToggles.jaegerBackendMigration = true;
- });
-
- afterEach(() => {
- config.featureToggles.jaegerBackendMigration = originalFeatureToggleValue;
- });
-
- it('should add node graph frames to response when nodeGraph is enabled and query is a trace ID query', async () => {
- // Create a datasource with nodeGraph enabled
- const settings = {
- ...defaultSettings,
- jsonData: {
- ...defaultSettings.jsonData,
- nodeGraph: { enabled: true },
- },
- };
-
- const ds = new JaegerDatasource(settings);
-
- // Mock the super.query method to return our mock response
- jest.spyOn(DataSourceWithBackend.prototype, 'query').mockImplementation(() => {
- return of({
- data: [
- {
- fields: testResponseDataFrameFields,
- values: testResponseDataFrameFields.values,
- },
- ],
- });
- });
-
- // Create a query without queryType (trace ID query)
- const query = {
...defaultQuery,
targets: [
{
@@ -353,189 +129,31 @@ describe('JaegerDatasource', () => {
refId: '1',
},
],
- };
+ })
+ );
- // Execute the query
- const response = await lastValueFrom(ds.query(query));
- // Verify that the response contains the original data plus node graph frames
- expect(response.data.length).toBe(3);
- });
-
- it('should not add node graph frames when nodeGraph is disabled', async () => {
- // Create a datasource with nodeGraph disabled
- const settings = {
- ...defaultSettings,
- jsonData: {
- ...defaultSettings.jsonData,
- nodeGraph: { enabled: false },
- },
- };
-
- const ds = new JaegerDatasource(settings);
-
- // Mock the super.query method to return our mock response
- jest.spyOn(DataSourceWithBackend.prototype, 'query').mockImplementation(() => {
- return of({
- data: [
- {
- fields: testResponseDataFrameFields,
- values: testResponseDataFrameFields.values,
- },
- ],
- });
- });
-
- // Create a query without queryType (trace ID query)
- const query = {
- ...defaultQuery,
- targets: [
- {
- query: '12345',
- refId: '1',
- },
- ],
- };
-
- // Execute the query
- const response = await lastValueFrom(ds.query(query));
- // Verify that the response contains only the original data
- expect(response.data.length).toBe(1);
- expect(response.data[0].fields).toMatchObject(testResponseDataFrameFields);
- });
+ expect(response.data.length).toBe(1);
});
});
-describe('when performing testDataSource', () => {
- describe('and call succeeds', () => {
- it('should return successfully', async () => {
- setupFetchMock({ data: ['service1'] });
-
- const ds = new JaegerDatasource(defaultSettings);
- const response = await ds.testDatasource();
- expect(response.status).toEqual('success');
- expect(response.message).toBe('Data source connected and services found.');
- });
- });
-
- describe('and call succeeds, but returns no services', () => {
- it('should display an error', async () => {
- setupFetchMock(undefined);
-
- const ds = new JaegerDatasource(defaultSettings);
- const response = await ds.testDatasource();
- expect(response.status).toEqual('error');
- expect(response.message).toBe(
- 'Data source connected, but no services received. Verify that Jaeger is configured properly.'
- );
- });
- });
-
- describe('and call returns error with message', () => {
- it('should return the formatted error', async () => {
- setupFetchMock(
- undefined,
- throwError({
- statusText: 'Not found',
- status: 404,
- data: {
- message: '404 page not found',
- },
- })
- );
-
- const ds = new JaegerDatasource(defaultSettings);
- const response = await ds.testDatasource();
- expect(response.status).toEqual('error');
- expect(response.message).toBe('Jaeger: Not found. 404. 404 page not found');
- });
- });
-
- describe('and call returns error without message', () => {
- it('should return JSON error', async () => {
- setupFetchMock(
- undefined,
- throwError({
- statusText: 'Bad gateway',
- status: 502,
- data: {
- errors: ['Could not connect to Jaeger backend'],
- },
- })
- );
-
- const ds = new JaegerDatasource(defaultSettings);
- const response = await ds.testDatasource();
- expect(response.status).toEqual('error');
- expect(response.message).toBe('Jaeger: Bad gateway. 502. {"errors":["Could not connect to Jaeger backend"]}');
- });
- });
-});
-
-describe('Test behavior with unmocked time', () => {
- // Tolerance for checking timestamps.
- // Using a lower number seems to cause flaky tests.
- const numDigits = -4;
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- it('getTimeRange()', async () => {
+describe('time range', () => {
+ it('should calculate correct time range', async () => {
const ds = new JaegerDatasource(defaultSettings);
const timeRange = ds.getTimeRange();
const now = Date.now();
- expect(timeRange.end).toBeCloseTo(now * 1000, numDigits);
- expect(timeRange.start).toBeCloseTo((now - 6 * 3600 * 1000) * 1000, numDigits);
- });
-
- it("call for `query()` when `queryType === 'dependencyGraph'`", async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- const now = Date.now();
-
- ds.query({ ...defaultQuery, targets: [{ queryType: 'dependencyGraph', refId: '1' }] });
-
- const url = mock.mock.calls[0][0].url;
- const endTsMatch = url.match(/endTs=(\d+)/);
- expect(endTsMatch).not.toBeNull();
- expect(parseInt(endTsMatch![1], 10)).toBeCloseTo(now, numDigits);
-
- const lookbackMatch = url.match(/lookback=(\d+)/);
- expect(lookbackMatch).not.toBeNull();
- expect(parseInt(lookbackMatch![1], 10)).toBeCloseTo(3600000, -1); // due to rounding, the least significant digit is not reliable
- });
-
- it("call for `query()` when `queryType === 'dependencyGraph'`, using default range", async () => {
- const mock = setupFetchMock({ data: [testResponse] });
- const ds = new JaegerDatasource(defaultSettings);
- const now = Date.now();
- const query = JSON.parse(JSON.stringify(defaultQuery));
- // @ts-ignore
- query.range = undefined;
-
- ds.query({ ...query, targets: [{ queryType: 'dependencyGraph', refId: '1' }] });
-
- const url = mock.mock.calls[0][0].url;
- const endTsMatch = url.match(/endTs=(\d+)/);
- expect(endTsMatch).not.toBeNull();
- expect(parseInt(endTsMatch![1], 10)).toBeCloseTo(now, numDigits);
-
- const lookbackMatch = url.match(/lookback=(\d+)/);
- expect(lookbackMatch).not.toBeNull();
- expect(parseInt(lookbackMatch![1], 10)).toBeCloseTo(21600000, -1);
+ expect(timeRange.end).toBeCloseTo(now * 1000, -4);
+ expect(timeRange.start).toBeCloseTo((now - 6 * 3600 * 1000) * 1000, -4);
});
});
-function setupFetchMock(response: unknown, mock?: ReturnType) {
- const defaultMock = () => mock ?? of(createFetchResponse(response));
-
- const fetchMock = jest.spyOn(backendSrv, 'fetch');
- fetchMock.mockImplementation(defaultMock);
- return fetchMock;
+function setupQueryMock(type: 'trace' | 'search') {
+ return jest.spyOn(DataSourceWithBackend.prototype, 'query').mockImplementation(() => {
+ if (type === 'search') {
+ return of(mockSearchResponse);
+ } else {
+ return of(mockTraceResponse);
+ }
+ });
}
const defaultSettings: DataSourceInstanceSettings = {
@@ -560,3 +178,25 @@ const defaultSettings: DataSourceInstanceSettings = {
},
readOnly: false,
};
+
+const defaultQuery: DataQueryRequest = {
+ requestId: '1',
+ interval: '0',
+ intervalMs: 10,
+ panelId: 0,
+ scopedVars: {},
+ range: {
+ from: dateTime().subtract(1, 'h'),
+ to: dateTime(),
+ raw: { from: '1h', to: 'now' },
+ },
+ timezone: 'browser',
+ app: 'explore',
+ startTime: 0,
+ targets: [
+ {
+ query: '12345',
+ refId: '1',
+ },
+ ],
+};
diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts
index 6aac84c3ca0..11c6a8a3f3b 100644
--- a/public/app/plugins/datasource/jaeger/datasource.ts
+++ b/public/app/plugins/datasource/jaeger/datasource.ts
@@ -1,6 +1,5 @@
-import { identity, omit, pick, pickBy } from 'lodash';
-import { lastValueFrom, Observable, of } from 'rxjs';
-import { catchError, map } from 'rxjs/operators';
+import { Observable, of } from 'rxjs';
+import { map } from 'rxjs/operators';
import {
DataQueryRequest,
@@ -14,25 +13,14 @@ import {
MutableDataFrame,
ScopedVars,
toDataFrame,
- urlUtil,
} from '@grafana/data';
import { createNodeGraphFrames, NodeGraphOptions, SpanBarOptions } from '@grafana/o11y-ds-frontend';
-import {
- BackendSrvRequest,
- config,
- DataSourceWithBackend,
- getBackendSrv,
- getTemplateSrv,
- TemplateSrv,
-} from '@grafana/runtime';
+import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
-import { ALL_OPERATIONS_KEY } from './components/SearchForm';
import { TraceIdTimeParamsOptions } from './configuration/TraceIdTimeParams';
-import { mapJaegerDependenciesResponse } from './dependencyGraphTransform';
import { createGraphFrames } from './graphTransform';
-import { createTableFrame, createTraceFrame } from './responseTransform';
+import { createTraceFrame } from './responseTransform';
import { JaegerQuery } from './types';
-import { convertTagsLogfmt } from './util';
export interface JaegerJsonData extends DataSourceJsonData {
nodeGraph?: NodeGraphOptions;
@@ -45,7 +33,7 @@ export class JaegerDatasource extends DataSourceWithBackend,
+ instanceSettings: DataSourceInstanceSettings,
private readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings);
@@ -53,25 +41,14 @@ export class JaegerDatasource extends DataSourceWithBackend) {
- if (config.featureToggles.jaegerBackendMigration) {
- return await this.getResource(url, params);
- }
-
- const res = await lastValueFrom(this._request('/api/' + url, params, { hideFromInspector: true }));
- return res.data.data;
+ return await this.getResource(url, params);
}
isSearchFormValid(query: JaegerQuery): boolean {
return !!query.service;
}
- /**
- * Migrated to backend with feature toggle `jaegerBackendMigration`
- */
query(options: DataQueryRequest): Observable {
// At this moment we expect only one target. In case we somehow change the UI to be able to show multiple
// traces at one we need to change this.
@@ -80,55 +57,6 @@ export class JaegerDatasource extends DataSourceWithBackend {
- // If the node graph is enabled and the query is a trace ID query, add the node graph frames to the response
- if (this.nodeGraph?.enabled && !target.queryType) {
- return addNodeGraphFramesToResponse(response);
- }
- return response;
- })
- );
- }
-
- // Use the internal Jaeger /dependencies API for rendering the dependency graph.
- if (target.queryType === 'dependencyGraph') {
- const timeRange = options.range ?? getDefaultTimeRange();
- const endTs = getTime(timeRange.to, true) / 1000;
- const lookback = endTs - getTime(timeRange.from, false) / 1000;
- return this._request('/api/dependencies', { endTs, lookback }).pipe(map(mapJaegerDependenciesResponse));
- }
-
- if (target.queryType === 'search' && !this.isSearchFormValid(target)) {
- return of({ error: { message: 'You must select a service.' }, data: [] });
- }
-
- let { start, end } = this.getTimeRange(options.range);
-
- if (target.queryType !== 'search' && target.query) {
- let url = `/api/traces/${encodeURIComponent(this.templateSrv.replace(target.query.trim(), options.scopedVars))}`;
- if (this.traceIdTimeParams) {
- url += `?start=${start}&end=${end}`;
- }
-
- return this._request(url).pipe(
- map((response) => {
- const traceData = response?.data?.data?.[0];
- if (!traceData) {
- return { data: [emptyTraceDataFrame] };
- }
- let data = [createTraceFrame(traceData)];
- if (this.nodeGraph?.enabled) {
- data.push(...createGraphFrames(traceData));
- }
- return {
- data,
- };
- })
- );
- }
-
if (target.queryType === 'upload') {
if (!this.uploadedJson) {
return of({ data: [] });
@@ -146,38 +74,13 @@ export class JaegerDatasource extends DataSourceWithBackend {
- return {
- data: [createTableFrame(response.data.data, this.instanceSettings)],
- };
+ // If the node graph is enabled and the query is a trace ID query, add the node graph frames to the response
+ if (this.nodeGraph?.enabled && !target.queryType) {
+ return addNodeGraphFramesToResponse(response);
+ }
+ return response;
})
);
}
@@ -215,49 +118,8 @@ export class JaegerDatasource extends DataSourceWithBackend {
- const values = res?.data?.data || [];
- const testResult =
- values.length > 0
- ? { status: 'success', message: 'Data source connected and services found.' }
- : {
- status: 'error',
- message:
- 'Data source connected, but no services received. Verify that Jaeger is configured properly.',
- };
- return testResult;
- }),
- catchError((err) => {
- let message = 'Jaeger: ';
- if (err.statusText) {
- message += err.statusText;
- } else {
- message += 'Cannot connect to Jaeger';
- }
-
- if (err.status) {
- message += `. ${err.status}`;
- }
-
- if (err.data && err.data.message) {
- message += `. ${err.data.message}`;
- } else if (err.data) {
- message += `. ${JSON.stringify(err.data)}`;
- }
- return of({ status: 'error', message: message });
- })
- )
- );
+ return await super.testDatasource();
}
getTimeRange(range = getDefaultTimeRange()): { start: number; end: number } {
@@ -270,21 +132,6 @@ export class JaegerDatasource extends DataSourceWithBackend,
- options?: Partial
- ): Observable> {
- const params = data ? urlUtil.serializeParams(data) : '';
- const url = `${this.instanceSettings.url}${apiUrl}${params.length ? `?${params}` : ''}`;
- const req = {
- ...options,
- url,
- };
-
- return getBackendSrv().fetch(req);
- }
}
function getTime(date: string | DateTime, roundUp: boolean) {
diff --git a/public/app/plugins/datasource/jaeger/mockSearchResponse.json b/public/app/plugins/datasource/jaeger/mockSearchResponse.json
new file mode 100644
index 00000000000..7ce11be4aea
--- /dev/null
+++ b/public/app/plugins/datasource/jaeger/mockSearchResponse.json
@@ -0,0 +1,52 @@
+{
+ "data": [
+ {
+ "fields": [
+ {
+ "name": "traceID",
+ "values": ["test-trace-id"],
+ "config": {
+ "displayName": "Trace ID",
+ "links": [
+ {
+ "title": "Trace: ${__value.raw}",
+ "internal": {
+ "query": {
+ "query": "${__value.raw}"
+ },
+ "datasourceUid": "test-uid",
+ "datasourceName": "test-name"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "traceName",
+ "values": ["test-service: test-operation"],
+ "config": {
+ "displayName": "Trace name"
+ }
+ },
+ {
+ "name": "startTime",
+ "values": [1605873894680],
+ "config": {
+ "displayName": "Start time"
+ }
+ },
+ {
+ "name": "duration",
+ "values": [1000],
+ "config": {
+ "displayName": "Duration",
+ "unit": "µs"
+ }
+ }
+ ],
+ "meta": {
+ "preferredVisualisationType": "table"
+ }
+ }
+ ]
+}
diff --git a/public/app/plugins/datasource/jaeger/mockTraceResponse.json b/public/app/plugins/datasource/jaeger/mockTraceResponse.json
new file mode 100644
index 00000000000..08867e2197f
--- /dev/null
+++ b/public/app/plugins/datasource/jaeger/mockTraceResponse.json
@@ -0,0 +1,21 @@
+{
+ "data": [
+ {
+ "fields": [
+ { "name": "traceID", "values": ["3fa414edcef6ad90", "3fa414edcef6ad90"] },
+ { "name": "spanID", "values": ["3fa414edcef6ad90", "0f5c1808567e4403"] },
+ { "name": "parentSpanID", "values": [null, "3fa414edcef6ad90"] },
+ { "name": "operationName", "values": ["HTTP GET - api_traces_traceid", "/tempopb.Querier/FindTraceByID"] },
+ { "name": "serviceName", "values": ["tempo-querier", "tempo-querier"] },
+ { "name": "startTime", "values": [1605873894680.409, 1605873894680.587] },
+ { "name": "duration", "values": [1049.141, 1.847] }
+ ],
+ "meta": {
+ "preferredVisualisationType": "trace",
+ "custom": {
+ "traceFormat": "jaeger"
+ }
+ }
+ }
+ ]
+}
From 190b9e1b06520bdf820278236a40b4def5d651b6 Mon Sep 17 00:00:00 2001
From: Dominik Prokop
Date: Thu, 10 Jul 2025 16:54:29 +0200
Subject: [PATCH 30/33] kubernetesDashboards: Fix dashboard export e2e test
failing with v1 k8s API enabled (#107900)
Fix dashboard export e2e
---
.../sharing/ExportButton/ResourceExport.tsx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
index 0fe12b9e977..edc4aaf3384 100644
--- a/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
+++ b/public/app/features/dashboard-scene/sharing/ExportButton/ResourceExport.tsx
@@ -1,5 +1,6 @@
import { AsyncState } from 'react-use/lib/useAsync';
+import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
@@ -28,6 +29,8 @@ interface Props {
onViewYAML: () => void;
}
+const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
+
export function ResourceExport({
dashboardJson,
isSharingExternally,
@@ -110,7 +113,12 @@ export function ResourceExport({
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
-
+
)}
From a027575435abc4868a13d9f851aa29ac50e401ee Mon Sep 17 00:00:00 2001
From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
Date: Thu, 10 Jul 2025 08:28:51 -0700
Subject: [PATCH 31/33] Transformations: Prototype search tags (#105797)
* feat: init
* chore: i18n + improve formatting/style generally
* fix: i18n syntax...
* chore: i18n extract
* chore: some cleanup
* chore: cleanup
* chore: i18n
---
.../standardTransformersRegistry.ts | 5 ++
.../PanelDataPane/TransformationsDrawer.tsx | 11 ++--
.../TransformationPickerNg.tsx | 60 ++++++++++++++-----
.../editors/ConcatenateTransformerEditor.tsx | 1 +
.../ConvertFieldTypeTransformerEditor.tsx | 1 +
.../editors/TransposeTransformerEditor.tsx | 5 +-
public/locales/en-US/grafana.json | 1 +
7 files changed, 62 insertions(+), 22 deletions(-)
diff --git a/packages/grafana-data/src/transformations/standardTransformersRegistry.ts b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts
index 4e5a11d318a..54166fe33d1 100644
--- a/packages/grafana-data/src/transformations/standardTransformersRegistry.ts
+++ b/packages/grafana-data/src/transformations/standardTransformersRegistry.ts
@@ -34,6 +34,11 @@ export interface TransformerRegistryItem extends RegistryItem {
* Set of categories associated with the transformer
*/
categories?: Set;
+
+ /**
+ * Set of tags associated with the transformer for improved transformation search
+ */
+ tags?: Set;
}
export enum TransformerCategory {
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx
index fa88f3b5251..c00f4233fc1 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationsDrawer.tsx
@@ -52,10 +52,13 @@ export function TransformationsDrawer(props: TransformationsDrawerProps) {
) {
return false;
}
- return (
- t.name.toLocaleLowerCase().includes(drawerState.search.toLocaleLowerCase()) ||
- t.description?.toLocaleLowerCase().includes(drawerState.search.toLocaleLowerCase())
- );
+ const searchLower = drawerState.search.toLocaleLowerCase();
+ const textMatch =
+ t.name.toLocaleLowerCase().includes(searchLower) || t.description?.toLocaleLowerCase().includes(searchLower);
+ const tagMatch = t.tags?.size
+ ? Array.from(t.tags).some((tag) => tag.toLocaleLowerCase().includes(searchLower))
+ : false;
+ return textMatch || tagMatch;
});
const searchBoxSuffix = (
diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx
index ea710a890e5..4dab1da8b45 100644
--- a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx
+++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx
@@ -12,7 +12,7 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
-import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
+import { Badge, Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { categoriesLabels } from 'app/features/transformers/utils';
@@ -146,6 +146,7 @@ function getTransformationPickerStyles(theme: GrafanaTheme2) {
columnGap: '27px',
rowGap: '16px',
width: '100%',
+ paddingBottom: theme.spacing(1),
}),
searchInput: css({
flexGrow: '1',
@@ -208,10 +209,24 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data
key={transform.id}
>
- {transform.name}
-
-
-
+
+
{transform.name}
+
+
+
+
+ {transform.tags && transform.tags.size > 0 && (
+
+ {Array.from(transform.tags).map((tag) => (
+
+ ))}
+
+ )}
{getTransformationsRedesignDescriptions(transform.id)}
@@ -242,11 +257,18 @@ function getTransformationGridStyles(theme: GrafanaTheme2) {
'> button': {
width: '100%',
display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center',
- flexWrap: 'nowrap',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: theme.spacing(1),
},
}),
+ titleRow: css({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ flexWrap: 'nowrap',
+ width: '100%',
+ }),
description: css({
fontSize: '12px',
display: 'flex',
@@ -255,19 +277,19 @@ function getTransformationGridStyles(theme: GrafanaTheme2) {
}),
image: css({
display: 'block',
- maxEidth: '100%`',
- marginTop: `${theme.spacing(2)}`,
+ maxWidth: '100%',
+ marginTop: theme.spacing(2),
}),
grid: css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gridAutoRows: '1fr',
- gap: `${theme.spacing(2)} ${theme.spacing(1)}`,
+ gap: theme.spacing(1),
width: '100%',
+ padding: `${theme.spacing(1)} 0`,
}),
cardDisabled: css({
- backgroundColor: 'rgb(204, 204, 220, 0.045)',
- color: `${theme.colors.text.disabled} !important`,
+ backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
@@ -275,14 +297,20 @@ function getTransformationGridStyles(theme: GrafanaTheme2) {
}),
cardApplicableInfo: css({
position: 'absolute',
- bottom: `${theme.spacing(1)}`,
- right: `${theme.spacing(1)}`,
+ bottom: theme.spacing(1),
+ right: theme.spacing(1),
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
+ marginBottom: 0,
}),
pluginStateInfoWrapper: css({
- marginLeft: '5px',
+ marginLeft: theme.spacing(0.5),
+ }),
+ tagsWrapper: css({
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: theme.spacing(0.5),
}),
};
}
diff --git a/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx
index d9dc1cff6ec..d106bbfadeb 100644
--- a/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx
+++ b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx
@@ -91,4 +91,5 @@ export const concatenateTransformRegistryItem: TransformerRegistryItem) => {
+export const TransposeTransformerEditor = ({ options, onChange }: TransformerUIProps) => {
return (
<>
@@ -45,9 +45,10 @@ export const TransposeTransfomerEditor = ({ options, onChange }: TransformerUIPr
export const transposeTransformerRegistryItem: TransformerRegistryItem = {
id: DataTransformerID.transpose,
- editor: TransposeTransfomerEditor,
+ editor: TransposeTransformerEditor,
transformation: standardTransformers.transposeTransformer,
name: standardTransformers.transposeTransformer.name,
description: standardTransformers.transposeTransformer.description,
categories: new Set([TransformerCategory.Reformat]),
+ tags: new Set(['Pivot', 'Translate', 'Transform']),
};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 2f22f8556d4..ab3d742f57d 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -12549,6 +12549,7 @@
"transform": "Transform"
}
},
+ "tag": "{{ tag }}",
"time-series-table-transform-editor": {
"label-stat": "Stat",
"label-time-field": "Time field",
From 9df15d120d7f60a4b8777cf396100f135ea23408 Mon Sep 17 00:00:00 2001
From: "alerting-team[bot]"
<158350966+alerting-team[bot]@users.noreply.github.com>
Date: Thu, 10 Jul 2025 15:47:17 +0000
Subject: [PATCH 32/33] Alerting: Update alerting module to
c5c6f9c1653d816439184c5ec580d3035feca417 (#107931)
[create-pull-request] automated change
Co-authored-by: yuri-tceretian <25988953+yuri-tceretian@users.noreply.github.com>
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 86ece166842..184d215dce7 100644
--- a/go.mod
+++ b/go.mod
@@ -87,7 +87,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
- github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 // @grafana/alerting-backend
+ github.com/grafana/alerting v0.0.0-20250709204613-c5c6f9c1653d // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
diff --git a/go.sum b/go.sum
index 53054254a04..483cc663c3d 100644
--- a/go.sum
+++ b/go.sum
@@ -1586,8 +1586,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
-github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 h1:3imTbxFpZSVI6IBIB9mn+Xc40lUweWjfMaBSgXR7rLs=
-github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
+github.com/grafana/alerting v0.0.0-20250709204613-c5c6f9c1653d h1:rtlYpwsE3KDWWCg2kytDw3s5qgpDjG87qh1IixAyNz4=
+github.com/grafana/alerting v0.0.0-20250709204613-c5c6f9c1653d/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed h1:k5Ng33zE9fCawqfEVybOasXY7/FQD5Qg2J92ePneeVM=
github.com/grafana/authlib v0.0.0-20250618124654-54543efcfeed/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI=
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM=
From 7e0848294e840dd7fc683015af6c8ec992934819 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?=
Date: Thu, 10 Jul 2025 18:46:38 +0200
Subject: [PATCH 33/33] Provisioning: Use Nanogit for basic git operations in
Github repository type (#107889)
---
go.mod | 11 -
go.sum | 28 -
.../src/types/featureToggles.gen.ts | 4 -
.../jobs/export/mock_export_fn.go | 2 +-
.../jobs/export/mock_wrap_with_clone_fn.go | 87 -
.../jobs/export/mock_wrap_with_stage_fn.go | 86 +
.../provisioning/jobs/export/resources.go | 5 +-
.../apis/provisioning/jobs/export/worker.go | 38 +-
.../provisioning/jobs/export/worker_test.go | 101 +-
.../apis/provisioning/jobs/migrate/legacy.go | 31 +-
.../provisioning/jobs/migrate/legacy_test.go | 56 +-
.../jobs/migrate/mock_bulk_store_client.go | 2 +-
.../migrate/mock_legacy_resources_migrator.go | 2 +-
.../jobs/migrate/mock_namespace_cleaner.go | 2 +-
.../jobs/migrate/mock_wrap_with_clone_fn.go | 87 -
.../jobs/migrate/unifiedstorage.go | 4 +-
.../provisioning/jobs/migrate/worker_test.go | 3 +-
pkg/registry/apis/provisioning/register.go | 79 +-
.../repository/clonable_repository_mock.go | 95 -
.../apis/provisioning/repository/clone.go | 44 -
.../provisioning/repository/clone_fn_mock.go | 95 -
.../provisioning/repository/clone_test.go | 144 -
.../repository/config_repository_mock.go | 2 +-
.../provisioning/repository/git/branch.go | 33 +
.../repository/git/branch_test.go | 47 +
.../provisioning/repository/{ => git}/git.go | 14 +-
.../{ => git}/git_repository_mock.go | 166 +-
.../{nanogit/git.go => git/repository.go} | 48 +-
.../git_test.go => git/repository_test.go} | 165 +-
.../repository/{nanogit => git}/staged.go | 47 +-
.../{nanogit => git}/staged_test.go | 141 +-
.../apis/provisioning/repository/github.go | 683 ----
.../provisioning/repository/github/client.go | 119 +-
.../provisioning/repository/github/factory.go | 9 +-
.../{ => github}/github_repository_mock.go | 196 +-
.../provisioning/repository/github/impl.go | 403 +--
.../repository/github/impl_test.go | 2159 -----------
.../repository/github/mock_client.go | 634 +---
.../repository/github/mock_commit_file.go | 2 +-
.../github/mock_repository_content.go | 312 --
.../repository/github/repository.go | 240 ++
.../repository/github/repository_test.go | 1014 ++++++
.../provisioning/repository/github_test.go | 3143 -----------------
.../repository/go-git/progress.go | 47 -
.../repository/go-git/progress_test.go | 58 -
.../repository/go-git/repository_mock.go | 84 -
.../repository/go-git/transport.go | 73 -
.../repository/go-git/transport_test.go | 140 -
.../repository/go-git/worktree_mock.go | 261 --
.../provisioning/repository/go-git/wrapper.go | 468 ---
.../repository/go-git/wrapper_test.go | 1642 ---------
.../repository/{ => local}/local.go | 35 +-
.../repository/{ => local}/local_test.go | 21 +-
.../provisioning/repository/nanogit/github.go | 125 -
.../repository/nanogit/github_test.go | 356 --
.../provisioning/repository/reader_mock.go | 2 +-
.../provisioning/repository/repository.go | 43 -
.../repository/stageable_repository_mock.go | 95 +
.../apis/provisioning/repository/staged.go | 71 +
...tory_mock.go => staged_repository_mock.go} | 207 +-
.../provisioning/repository/staged_test.go | 144 +
.../apis/provisioning/repository/test.go | 2 +-
.../apis/provisioning/repository/test_test.go | 2 +-
.../provisioning/repository/versioned_mock.go | 2 +-
.../apis/provisioning/resources/resources.go | 2 +-
.../apis/provisioning/webhooks/register.go | 42 +-
.../apis/provisioning/webhooks/repository.go | 6 +-
.../provisioning/webhooks/repository_test.go | 98 +-
pkg/services/featuremgmt/registry.go | 7 -
pkg/services/featuremgmt/toggles_gen.csv | 1 -
pkg/services/featuremgmt/toggles_gen.go | 4 -
pkg/services/featuremgmt/toggles_gen.json | 3 +-
pkg/tests/apis/provisioning/helper_test.go | 86 +-
.../apis/provisioning/provisioning_test.go | 72 +-
.../testdata/github-readonly.json.tmpl | 6 +-
75 files changed, 2426 insertions(+), 12362 deletions(-)
delete mode 100644 pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_clone_fn.go
create mode 100644 pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_stage_fn.go
delete mode 100644 pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_clone_fn.go
delete mode 100644 pkg/registry/apis/provisioning/repository/clonable_repository_mock.go
delete mode 100644 pkg/registry/apis/provisioning/repository/clone.go
delete mode 100644 pkg/registry/apis/provisioning/repository/clone_fn_mock.go
delete mode 100644 pkg/registry/apis/provisioning/repository/clone_test.go
create mode 100644 pkg/registry/apis/provisioning/repository/git/branch.go
create mode 100644 pkg/registry/apis/provisioning/repository/git/branch_test.go
rename pkg/registry/apis/provisioning/repository/{ => git}/git.go (60%)
rename pkg/registry/apis/provisioning/repository/{ => git}/git_repository_mock.go (87%)
rename pkg/registry/apis/provisioning/repository/{nanogit/git.go => git/repository.go} (94%)
rename pkg/registry/apis/provisioning/repository/{nanogit/git_test.go => git/repository_test.go} (96%)
rename pkg/registry/apis/provisioning/repository/{nanogit => git}/staged.go (86%)
rename pkg/registry/apis/provisioning/repository/{nanogit => git}/staged_test.go (88%)
delete mode 100644 pkg/registry/apis/provisioning/repository/github.go
rename pkg/registry/apis/provisioning/repository/{ => github}/github_repository_mock.go (85%)
delete mode 100644 pkg/registry/apis/provisioning/repository/github/mock_repository_content.go
create mode 100644 pkg/registry/apis/provisioning/repository/github/repository.go
create mode 100644 pkg/registry/apis/provisioning/repository/github/repository_test.go
delete mode 100644 pkg/registry/apis/provisioning/repository/github_test.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/progress.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/progress_test.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/repository_mock.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/transport.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/transport_test.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/wrapper.go
delete mode 100644 pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
rename pkg/registry/apis/provisioning/repository/{ => local}/local.go (90%)
rename pkg/registry/apis/provisioning/repository/{ => local}/local_test.go (98%)
delete mode 100644 pkg/registry/apis/provisioning/repository/nanogit/github.go
delete mode 100644 pkg/registry/apis/provisioning/repository/nanogit/github_test.go
create mode 100644 pkg/registry/apis/provisioning/repository/stageable_repository_mock.go
create mode 100644 pkg/registry/apis/provisioning/repository/staged.go
rename pkg/registry/apis/provisioning/repository/{cloned_repository_mock.go => staged_repository_mock.go} (54%)
create mode 100644 pkg/registry/apis/provisioning/repository/staged_test.go
diff --git a/go.mod b/go.mod
index 184d215dce7..112a1566345 100644
--- a/go.mod
+++ b/go.mod
@@ -58,8 +58,6 @@ require (
github.com/fullstorydev/grpchan v1.1.1 // @grafana/grafana-backend-group
github.com/gchaincl/sqlhooks v1.3.0 // @grafana/grafana-search-and-storage
github.com/getkin/kin-openapi v0.132.0 // @grafana/grafana-app-platform-squad
- github.com/go-git/go-billy/v5 v5.6.2 // @grafana/grafana-app-platform-squad
- github.com/go-git/go-git/v5 v5.14.0 // @grafana/grafana-app-platform-squad
github.com/go-jose/go-jose/v3 v3.0.4 // @grafana/identity-access-team
github.com/go-jose/go-jose/v4 v4.1.0 // indirect; @grafana/identity-access-team
github.com/go-kit/log v0.2.1 // @grafana/grafana-backend-group
@@ -276,7 +274,6 @@ require (
github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
- github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/RoaringBitmap/roaring v1.9.3 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
@@ -348,7 +345,6 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
- github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
@@ -372,7 +368,6 @@ require (
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gammazero/deque v0.2.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
- github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
@@ -423,7 +418,6 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jaegertracing/jaeger-idl v0.5.0 // indirect
- github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -437,7 +431,6 @@ require (
github.com/jpillora/backoff v1.0.0 // indirect
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
- github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/asmfmt v1.3.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
@@ -492,7 +485,6 @@ require (
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pires/go-proxyproto v0.7.0 // indirect
- github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
@@ -519,7 +511,6 @@ require (
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
@@ -538,7 +529,6 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
- github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
github.com/yudai/pp v2.0.1+incompatible // indirect
@@ -586,7 +576,6 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect
gopkg.in/telebot.v3 v3.2.1 // indirect
- gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/apiextensions-apiserver v0.33.2 // indirect
k8s.io/kms v0.33.2 // indirect
modernc.org/libc v1.65.0 // indirect
diff --git a/go.sum b/go.sum
index 483cc663c3d..55ad11fb6e8 100644
--- a/go.sum
+++ b/go.sum
@@ -744,7 +744,6 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -803,8 +802,6 @@ github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqR
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
-github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
-github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
@@ -1054,8 +1051,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
-github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
@@ -1200,8 +1195,6 @@ github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7M
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
-github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
@@ -1211,14 +1204,6 @@ github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY=
-github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
-github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
-github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
-github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
-github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
-github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -1815,8 +1800,6 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE=
github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -1877,8 +1860,6 @@ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
-github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
@@ -2175,8 +2156,6 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
-github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
-github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -2352,11 +2331,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
-github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
@@ -2481,8 +2457,6 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
-github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
-github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
@@ -3542,8 +3516,6 @@ gopkg.in/telebot.v3 v3.2.1 h1:3I4LohaAyJBiivGmkfB+CiVu7QFOWkuZ4+KHgO/G3rs=
gopkg.in/telebot.v3 v3.2.1/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
-gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index 81468c40d02..c88173cf331 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -210,10 +210,6 @@ export interface FeatureToggles {
*/
provisioning?: boolean;
/**
- * Use experimental git library for provisioning
- */
- nanoGit?: boolean;
- /**
* Start an additional https handler and write kubectl options
*/
grafanaAPIServerEnsureKubectlAccess?: boolean;
diff --git a/pkg/registry/apis/provisioning/jobs/export/mock_export_fn.go b/pkg/registry/apis/provisioning/jobs/export/mock_export_fn.go
index e200e43279f..e861e7da3c3 100644
--- a/pkg/registry/apis/provisioning/jobs/export/mock_export_fn.go
+++ b/pkg/registry/apis/provisioning/jobs/export/mock_export_fn.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package export
diff --git a/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_clone_fn.go b/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_clone_fn.go
deleted file mode 100644
index 51c1083ffe1..00000000000
--- a/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_clone_fn.go
+++ /dev/null
@@ -1,87 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package export
-
-import (
- context "context"
-
- repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockWrapWithCloneFn is an autogenerated mock type for the WrapWithCloneFn type
-type MockWrapWithCloneFn struct {
- mock.Mock
-}
-
-type MockWrapWithCloneFn_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockWrapWithCloneFn) EXPECT() *MockWrapWithCloneFn_Expecter {
- return &MockWrapWithCloneFn_Expecter{mock: &_m.Mock}
-}
-
-// Execute provides a mock function with given fields: ctx, repo, cloneOptions, pushOptions, fn
-func (_m *MockWrapWithCloneFn) Execute(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error) error {
- ret := _m.Called(ctx, repo, cloneOptions, pushOptions, fn)
-
- if len(ret) == 0 {
- panic("no return value specified for Execute")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error); ok {
- r0 = rf(ctx, repo, cloneOptions, pushOptions, fn)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockWrapWithCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
-type MockWrapWithCloneFn_Execute_Call struct {
- *mock.Call
-}
-
-// Execute is a helper method to define mock.On call
-// - ctx context.Context
-// - repo repository.Repository
-// - cloneOptions repository.CloneOptions
-// - pushOptions repository.PushOptions
-// - fn func(repository.Repository , bool) error
-func (_e *MockWrapWithCloneFn_Expecter) Execute(ctx interface{}, repo interface{}, cloneOptions interface{}, pushOptions interface{}, fn interface{}) *MockWrapWithCloneFn_Execute_Call {
- return &MockWrapWithCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, cloneOptions, pushOptions, fn)}
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error)) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.CloneOptions), args[3].(repository.PushOptions), args[4].(func(repository.Repository, bool) error))
- })
- return _c
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) Return(_a0 error) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockWrapWithCloneFn creates a new instance of MockWrapWithCloneFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockWrapWithCloneFn(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockWrapWithCloneFn {
- mock := &MockWrapWithCloneFn{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_stage_fn.go b/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_stage_fn.go
new file mode 100644
index 00000000000..eef200f8693
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/mock_wrap_with_stage_fn.go
@@ -0,0 +1,86 @@
+// Code generated by mockery v2.52.4. DO NOT EDIT.
+
+package export
+
+import (
+ context "context"
+
+ repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockWrapWithStageFn is an autogenerated mock type for the WrapWithStageFn type
+type MockWrapWithStageFn struct {
+ mock.Mock
+}
+
+type MockWrapWithStageFn_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockWrapWithStageFn) EXPECT() *MockWrapWithStageFn_Expecter {
+ return &MockWrapWithStageFn_Expecter{mock: &_m.Mock}
+}
+
+// Execute provides a mock function with given fields: ctx, repo, stageOptions, fn
+func (_m *MockWrapWithStageFn) Execute(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
+ ret := _m.Called(ctx, repo, stageOptions, fn)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Execute")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error); ok {
+ r0 = rf(ctx, repo, stageOptions, fn)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// MockWrapWithStageFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
+type MockWrapWithStageFn_Execute_Call struct {
+ *mock.Call
+}
+
+// Execute is a helper method to define mock.On call
+// - ctx context.Context
+// - repo repository.Repository
+// - stageOptions repository.StageOptions
+// - fn func(repository.Repository , bool) error
+func (_e *MockWrapWithStageFn_Expecter) Execute(ctx interface{}, repo interface{}, stageOptions interface{}, fn interface{}) *MockWrapWithStageFn_Execute_Call {
+ return &MockWrapWithStageFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, stageOptions, fn)}
+}
+
+func (_c *MockWrapWithStageFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error)) *MockWrapWithStageFn_Execute_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.StageOptions), args[3].(func(repository.Repository, bool) error))
+ })
+ return _c
+}
+
+func (_c *MockWrapWithStageFn_Execute_Call) Return(_a0 error) *MockWrapWithStageFn_Execute_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *MockWrapWithStageFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error) *MockWrapWithStageFn_Execute_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockWrapWithStageFn creates a new instance of MockWrapWithStageFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockWrapWithStageFn(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockWrapWithStageFn {
+ mock := &MockWrapWithStageFn{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go
index c469f4def8c..e02b7d7bfad 100644
--- a/pkg/registry/apis/provisioning/jobs/export/resources.go
+++ b/pkg/registry/apis/provisioning/jobs/export/resources.go
@@ -67,7 +67,7 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
}
- if err := exportResource(ctx, options, client, shim, repositoryResources, progress); err != nil {
+ if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
return fmt.Errorf("export %s: %w", kind.Resource, err)
}
}
@@ -76,6 +76,7 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
func exportResource(ctx context.Context,
+ resource string,
options provisioning.ExportJobOptions,
client dynamic.ResourceInterface,
shim conversionShim,
@@ -88,7 +89,7 @@ func exportResource(ctx context.Context,
gvk := item.GroupVersionKind()
result := jobs.JobResourceResult{
Name: item.GetName(),
- Resource: gvk.Kind,
+ Resource: resource,
Group: gvk.Group,
Action: repository.FileActionCreated,
}
diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go
index ca4df587476..5ded8555257 100644
--- a/pkg/registry/apis/provisioning/jobs/export/worker.go
+++ b/pkg/registry/apis/provisioning/jobs/export/worker.go
@@ -9,34 +9,33 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
//go:generate mockery --name ExportFn --structname MockExportFn --inpackage --filename mock_export_fn.go --with-expecter
type ExportFn func(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error
-//go:generate mockery --name WrapWithCloneFn --structname MockWrapWithCloneFn --inpackage --filename mock_wrap_with_clone_fn.go --with-expecter
-type WrapWithCloneFn func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repo repository.Repository, cloned bool) error) error
+//go:generate mockery --name WrapWithStageFn --structname MockWrapWithStageFn --inpackage --filename mock_wrap_with_stage_fn.go --with-expecter
+type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type ExportWorker struct {
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportFn ExportFn
- wrapWithCloneFn WrapWithCloneFn
+ wrapWithStageFn WrapWithStageFn
}
func NewExportWorker(
clientFactory resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
exportFn ExportFn,
- wrapWithCloneFn WrapWithCloneFn,
+ wrapWithStageFn WrapWithStageFn,
) *ExportWorker {
return &ExportWorker{
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportFn: exportFn,
- wrapWithCloneFn: wrapWithCloneFn,
+ wrapWithStageFn: wrapWithStageFn,
}
}
@@ -57,32 +56,9 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return err
}
- writer := gogit.Progress(func(line string) {
- progress.SetMessage(ctx, line)
- }, "finished")
-
- cloneOptions := repository.CloneOptions{
+ cloneOptions := repository.StageOptions{
Timeout: 10 * time.Minute,
PushOnWrites: false,
- Progress: writer,
- BeforeFn: func() error {
- progress.SetMessage(ctx, "clone target")
- // :( the branch is now baked into the repo
- if options.Branch != "" {
- return fmt.Errorf("branch is not supported for clonable repositories")
- }
-
- return nil
- },
- }
-
- pushOptions := repository.PushOptions{
- Timeout: 10 * time.Minute,
- Progress: writer,
- BeforeFn: func() error {
- progress.SetMessage(ctx, "push changes")
- return nil
- },
}
fn := func(repo repository.Repository, _ bool) error {
@@ -104,5 +80,5 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
- return r.wrapWithCloneFn(ctx, repo, cloneOptions, pushOptions, fn)
+ return r.wrapWithStageFn(ctx, repo, cloneOptions, fn)
}
diff --git a/pkg/registry/apis/provisioning/jobs/export/worker_test.go b/pkg/registry/apis/provisioning/jobs/export/worker_test.go
index abbf2b34143..aa5f2ea1c5e 100644
--- a/pkg/registry/apis/provisioning/jobs/export/worker_test.go
+++ b/pkg/registry/apis/provisioning/jobs/export/worker_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"time"
- "github.com/stretchr/testify/assert"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -143,12 +142,12 @@ func TestExportWorker_ProcessFailedToCreateClients(t *testing.T) {
mockClients := resources.NewMockClientFactory(t)
mockClients.On("Clients", context.Background(), "test-namespace").Return(nil, errors.New("failed to create clients"))
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, nil, nil, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute)
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
@@ -179,12 +178,12 @@ func TestExportWorker_ProcessNotReaderWriter(t *testing.T) {
mockClients.On("Clients", context.Background(), "test-namespace").Return(resourceClients, nil)
mockProgress := jobs.NewMockJobProgressRecorder(t)
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, nil, nil, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export job submitted targeting repository that is not a ReaderWriter")
}
@@ -216,16 +215,16 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
mockRepoResources.On("Client", context.Background(), mockRepo).Return(nil, fmt.Errorf("failed to create repository resources client"))
mockProgress := jobs.NewMockJobProgressRecorder(t)
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, mockRepoResources, nil, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, mockRepoResources, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "create repository resource client: failed to create repository resources client")
}
-func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
+func TestExportWorker_ProcessStageOptions(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -245,9 +244,7 @@ func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
- // Verify progress messages are set
- mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
- mockProgress.On("SetMessage", mock.Anything, "push changes").Return()
+ // No progress messages expected in current implementation
mockClients := resources.NewMockClientFactory(t)
mockResourceClients := resources.NewMockResourceClients(t)
@@ -260,21 +257,15 @@ func TestExportWorker_ProcessCloneAndPushOptions(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
- mockCloneFn := NewMockWrapWithCloneFn(t)
+ mockStageFn := NewMockWrapWithStageFn(t)
// Verify clone and push options
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.CloneOptions) bool {
- return opts.Timeout == 10*time.Minute && !opts.PushOnWrites && opts.BeforeFn != nil
- }), mock.MatchedBy(func(opts repository.PushOptions) bool {
- return opts.Timeout == 10*time.Minute && opts.Progress != nil && opts.BeforeFn != nil
- }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
- // Execute both BeforeFn functions to verify progress messages
- assert.NoError(t, cloneOpts.BeforeFn())
- assert.NoError(t, pushOpts.BeforeFn())
-
+ mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
+ return opts.Timeout == 10*time.Minute && !opts.PushOnWrites
+ }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -310,17 +301,17 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("export failed"))
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
-func TestExportWorker_ProcessWrapWithCloneFnError(t *testing.T) {
+func TestExportWorker_ProcessWrapWithStageFnError(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -340,15 +331,15 @@ func TestExportWorker_ProcessWrapWithCloneFnError(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("clone failed"))
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed"))
- r := NewExportWorker(nil, nil, nil, mockCloneFn.Execute)
+ r := NewExportWorker(nil, nil, nil, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
- require.EqualError(t, err, "clone failed")
+ require.EqualError(t, err, "stage failed")
}
-func TestExportWorker_ProcessBranchNotAllowedForClonableRepositories(t *testing.T) {
+func TestExportWorker_ProcessBranchNotAllowedForStageableRepositories(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
@@ -362,24 +353,16 @@ func TestExportWorker_ProcessBranchNotAllowedForClonableRepositories(t *testing.
mockRepo.On("Config").Return(&v0alpha1.Repository{
Spec: v0alpha1.RepositorySpec{
Type: v0alpha1.GitHubRepositoryType,
- Workflows: []v0alpha1.Workflow{v0alpha1.BranchWorkflow},
+ Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow}, // Only write workflow, not branch
},
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
- mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
- if cloneOpts.BeforeFn != nil {
- return cloneOpts.BeforeFn()
- }
+ // No progress messages expected in current implementation
- return fn(repo, true)
- })
-
- r := NewExportWorker(nil, nil, nil, mockCloneFn.Execute)
+ r := NewExportWorker(nil, nil, nil, nil)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
- require.EqualError(t, err, "branch is not supported for clonable repositories")
+ require.EqualError(t, err, "this repository does not support the branch workflow")
}
func TestExportWorker_ProcessGitRepository(t *testing.T) {
@@ -407,9 +390,7 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
- // Verify progress messages are set
- mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
- mockProgress.On("SetMessage", mock.Anything, "push changes").Return()
+ // No progress messages expected in current implementation
mockClients := resources.NewMockClientFactory(t)
mockResourceClients := resources.NewMockResourceClients(t)
@@ -422,21 +403,15 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
- mockCloneFn := NewMockWrapWithCloneFn(t)
+ mockStageFn := NewMockWrapWithStageFn(t)
// Verify clone and push options
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.CloneOptions) bool {
- return opts.Timeout == 10*time.Minute && !opts.PushOnWrites && opts.BeforeFn != nil
- }), mock.MatchedBy(func(opts repository.PushOptions) bool {
- return opts.Timeout == 10*time.Minute && opts.Progress != nil && opts.BeforeFn != nil
- }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
- // Execute both BeforeFn functions to verify progress messages
- assert.NoError(t, cloneOpts.BeforeFn())
- assert.NoError(t, pushOpts.BeforeFn())
-
+ mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
+ return opts.Timeout == 10*time.Minute && !opts.PushOnWrites
+ }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -477,12 +452,12 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("export failed"))
- mockCloneFn := NewMockWrapWithCloneFn(t)
- mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ mockStageFn := NewMockWrapWithStageFn(t)
+ mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
- r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
+ r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy.go
index ad2754bc444..191e0c4922f 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/legacy.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy.go
@@ -10,57 +10,38 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
)
type LegacyMigrator struct {
legacyMigrator LegacyResourcesMigrator
storageSwapper StorageSwapper
syncWorker jobs.Worker
- wrapWithCloneFn WrapWithCloneFn
+ wrapWithStageFn WrapWithStageFn
}
func NewLegacyMigrator(
legacyMigrator LegacyResourcesMigrator,
storageSwapper StorageSwapper,
syncWorker jobs.Worker,
- wrapWithCloneFn WrapWithCloneFn,
+ wrapWithStageFn WrapWithStageFn,
) *LegacyMigrator {
return &LegacyMigrator{
legacyMigrator: legacyMigrator,
storageSwapper: storageSwapper,
syncWorker: syncWorker,
- wrapWithCloneFn: wrapWithCloneFn,
+ wrapWithStageFn: wrapWithStageFn,
}
}
func (m *LegacyMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
namespace := rw.Config().Namespace
-
- writer := gogit.Progress(func(line string) {
- progress.SetMessage(ctx, line)
- }, "finished")
- cloneOptions := repository.CloneOptions{
+ stageOptions := repository.StageOptions{
PushOnWrites: options.History,
// TODO: make this configurable
- Timeout: 10 * time.Minute,
- Progress: writer,
- BeforeFn: func() error {
- progress.SetMessage(ctx, "clone repository")
- return nil
- },
- }
- pushOptions := repository.PushOptions{
- // TODO: make this configurable
- Timeout: 10 * time.Minute,
- Progress: writer,
- BeforeFn: func() error {
- progress.SetMessage(ctx, "push changes")
- return nil
- },
+ Timeout: 10 * time.Minute,
}
- if err := m.wrapWithCloneFn(ctx, rw, cloneOptions, pushOptions, func(repo repository.Repository, cloned bool) error {
+ if err := m.wrapWithStageFn(ctx, rw, stageOptions, func(repo repository.Repository, staged bool) error {
rw, ok := repo.(repository.ReaderWriter)
if !ok {
return errors.New("migration job submitted targeting repository that is not a ReaderWriter")
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go
index 5dc64480333..877bd01fd33 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go
@@ -3,7 +3,6 @@ package migrate
import (
"context"
"errors"
- "fmt"
"testing"
"time"
@@ -16,12 +15,12 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
-func TestWrapWithCloneFn(t *testing.T) {
+func TestWrapWithStageFn(t *testing.T) {
t.Run("should return error when repository is not a ReaderWriter", func(t *testing.T) {
// Setup
ctx := context.Background()
// Create the wrapper function that matches WrapWithCloneFn signature
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
// pass a reader to function call
repo := repository.NewMockReader(t)
return fn(repo, true)
@@ -56,7 +55,7 @@ func TestWrapWithCloneFn_Error(t *testing.T) {
expectedErr := errors.New("clone failed")
// Create the wrapper function that returns an error
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return expectedErr
}
@@ -98,7 +97,7 @@ func TestLegacyMigrator_MigrateFails(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -147,7 +146,7 @@ func TestLegacyMigrator_ResetUnifiedStorageFails(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -202,7 +201,7 @@ func TestLegacyMigrator_SyncFails(t *testing.T) {
}), mock.Anything).Return(expectedErr)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -257,7 +256,7 @@ func TestLegacyMigrator_SyncFails(t *testing.T) {
}), mock.Anything).Return(syncErr)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -310,7 +309,7 @@ func TestLegacyMigrator_Success(t *testing.T) {
}), mock.Anything).Return(nil)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
@@ -352,19 +351,7 @@ func TestLegacyMigrator_BeforeFnExecution(t *testing.T) {
mockStorageSwapper := NewMockStorageSwapper(t)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
- if clone.BeforeFn != nil {
- if err := clone.BeforeFn(); err != nil {
- return err
- }
- }
-
- if push.BeforeFn != nil {
- if err := push.BeforeFn(); err != nil {
- return err
- }
- }
-
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
@@ -376,8 +363,7 @@ func TestLegacyMigrator_BeforeFnExecution(t *testing.T) {
)
progress := jobs.NewMockJobProgressRecorder(t)
- progress.On("SetMessage", mock.Anything, "clone repository").Return()
- progress.On("SetMessage", mock.Anything, "push changes").Return()
+ // No progress messages expected in current staging implementation
// Execute
repo := repository.NewMockRepository(t)
@@ -399,19 +385,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
- wrapFn := func(ctx context.Context, rw repository.Repository, clone repository.CloneOptions, push repository.PushOptions, fn func(repository.Repository, bool) error) error {
- if clone.Progress != nil {
- if _, err := clone.Progress.Write([]byte("clone repository\n")); err != nil {
- return fmt.Errorf("failed to write to clone progress in tests: %w", err)
- }
- }
-
- if push.Progress != nil {
- if _, err := push.Progress.Write([]byte("push changes\n")); err != nil {
- return fmt.Errorf("failed to write to push progress in tests: %w", err)
- }
- }
-
+ wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
@@ -423,8 +397,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
)
progress := jobs.NewMockJobProgressRecorder(t)
- progress.On("SetMessage", mock.Anything, "clone repository").Return()
- progress.On("SetMessage", mock.Anything, "push changes").Return()
+ // No progress messages expected in current staging implementation
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
@@ -437,10 +410,7 @@ func TestLegacyMigrator_ProgressScanner(t *testing.T) {
require.EqualError(t, err, "migrate from SQL: abort test here")
require.Eventually(t, func() bool {
- if len(progress.Calls) != 2 {
- return false
- }
-
+ // No progress message calls expected in current staging implementation
return progress.AssertExpectations(t)
}, time.Second, 10*time.Millisecond)
})
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_store_client.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_store_client.go
index d6ab3f82fd2..9fa21e12ab0 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_store_client.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_store_client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_legacy_resources_migrator.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_legacy_resources_migrator.go
index 4aef34066fc..4a0d27fafb5 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/mock_legacy_resources_migrator.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/mock_legacy_resources_migrator.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_namespace_cleaner.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_namespace_cleaner.go
index 1ae1afa8ccf..2b168bfa202 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/mock_namespace_cleaner.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/mock_namespace_cleaner.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_clone_fn.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_clone_fn.go
deleted file mode 100644
index 8ab2973de33..00000000000
--- a/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_clone_fn.go
+++ /dev/null
@@ -1,87 +0,0 @@
-// Code generated by mockery v2.52.4. DO NOT EDIT.
-
-package migrate
-
-import (
- context "context"
-
- repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockWrapWithCloneFn is an autogenerated mock type for the WrapWithCloneFn type
-type MockWrapWithCloneFn struct {
- mock.Mock
-}
-
-type MockWrapWithCloneFn_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockWrapWithCloneFn) EXPECT() *MockWrapWithCloneFn_Expecter {
- return &MockWrapWithCloneFn_Expecter{mock: &_m.Mock}
-}
-
-// Execute provides a mock function with given fields: ctx, repo, cloneOptions, pushOptions, fn
-func (_m *MockWrapWithCloneFn) Execute(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error) error {
- ret := _m.Called(ctx, repo, cloneOptions, pushOptions, fn)
-
- if len(ret) == 0 {
- panic("no return value specified for Execute")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error); ok {
- r0 = rf(ctx, repo, cloneOptions, pushOptions, fn)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockWrapWithCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
-type MockWrapWithCloneFn_Execute_Call struct {
- *mock.Call
-}
-
-// Execute is a helper method to define mock.On call
-// - ctx context.Context
-// - repo repository.Repository
-// - cloneOptions repository.CloneOptions
-// - pushOptions repository.PushOptions
-// - fn func(repository.Repository , bool) error
-func (_e *MockWrapWithCloneFn_Expecter) Execute(ctx interface{}, repo interface{}, cloneOptions interface{}, pushOptions interface{}, fn interface{}) *MockWrapWithCloneFn_Execute_Call {
- return &MockWrapWithCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, cloneOptions, pushOptions, fn)}
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error)) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.CloneOptions), args[3].(repository.PushOptions), args[4].(func(repository.Repository, bool) error))
- })
- return _c
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) Return(_a0 error) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockWrapWithCloneFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error) *MockWrapWithCloneFn_Execute_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockWrapWithCloneFn creates a new instance of MockWrapWithCloneFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockWrapWithCloneFn(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockWrapWithCloneFn {
- mock := &MockWrapWithCloneFn{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go
index 2879d14fe7e..e2caeb7b3f4 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go
@@ -9,8 +9,8 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
-//go:generate mockery --name WrapWithCloneFn --structname MockWrapWithCloneFn --inpackage --filename mock_wrap_with_clone_fn.go --with-expecter
-type WrapWithCloneFn func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repo repository.Repository, cloned bool) error) error
+//go:generate mockery --name WrapWithStageFn --structname MockWrapWithStageFn --inpackage --filename mock_wrap_with_stage_fn.go --with-expecter
+type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type UnifiedStorageMigrator struct {
namespaceCleaner NamespaceCleaner
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go
index 1524487bf75..31242a48189 100644
--- a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go
+++ b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go
@@ -12,6 +12,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/local"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
@@ -88,7 +89,7 @@ func TestMigrationWorker_WithHistory(t *testing.T) {
progressRecorder.On("SetTotal", mock.Anything, 10).Return()
progressRecorder.On("Strict").Return()
- repo := repository.NewLocal(&provisioning.Repository{}, nil)
+ repo := local.NewLocal(&provisioning.Repository{}, nil)
err := worker.Process(context.Background(), repo, job, progressRecorder)
require.EqualError(t, err, "history is only supported for github repositories")
})
diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go
index fe5087ca1d3..3b6a2775ba7 100644
--- a/pkg/registry/apis/provisioning/register.go
+++ b/pkg/registry/apis/provisioning/register.go
@@ -45,9 +45,9 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
- gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/local"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
@@ -76,7 +76,7 @@ type APIBuilder struct {
features featuremgmt.FeatureToggles
getter rest.Getter
- localFileResolver *repository.LocalFolderResolver
+ localFileResolver *local.LocalFolderResolver
parsers resources.ParserFactory
repositoryResources resources.RepositoryResourcesFactory
clients resources.ClientFactory
@@ -105,7 +105,7 @@ type APIBuilder struct {
// It avoids anything that is core to Grafana, such that it can be used in a multi-tenant service down the line.
// This means there are no hidden dependencies, and no use of e.g. *settings.Cfg.
func NewAPIBuilder(
- local *repository.LocalFolderResolver,
+ local *local.LocalFolderResolver,
features featuremgmt.FeatureToggles,
unified resource.ResourceClient,
clonedir string, // where repo clones are managed
@@ -168,14 +168,7 @@ func RegisterAPIService(
return nil, nil
}
- logger := logging.DefaultLogger.With("logger", "provisioning startup")
- if features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
- logger.Info("Using nanogit for repositories")
- } else {
- logger.Debug("Using go-git and Github API for repositories")
- }
-
- folderResolver := &repository.LocalFolderResolver{
+ folderResolver := &local.LocalFolderResolver{
PermittedPrefixes: cfg.PermittedProvisioningPaths,
HomePath: safepath.Clean(cfg.HomePath),
}
@@ -606,11 +599,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.repositoryLister = repoInformer.Lister()
+ stageIfPossible := repository.WrapWithStageAndPushIfPossible
exportWorker := export.NewExportWorker(
b.clients,
b.repositoryResources,
export.ExportAll,
- repository.WrapWithCloneAndPushIfPossible,
+ stageIfPossible,
)
b.statusPatcher = controller.NewRepositoryStatusPatcher(b.GetClient())
@@ -636,7 +630,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
legacyResources,
storageSwapper,
syncWorker,
- repository.WrapWithCloneAndPushIfPossible,
+ stageIfPossible,
)
cleaner := migrate.NewNamespaceCleaner(b.clients)
@@ -1170,52 +1164,63 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
switch r.Spec.Type {
case provisioning.LocalRepositoryType:
- return repository.NewLocal(r, b.localFileResolver), nil
+ return local.NewLocal(r, b.localFileResolver), nil
case provisioning.GitRepositoryType:
- return nanogit.NewGitRepository(ctx, b.secrets, r, nanogit.RepositoryConfig{
+ // Decrypt token if needed
+ token := r.Spec.Git.Token
+ if token == "" {
+ decrypted, err := b.secrets.Decrypt(ctx, r.Spec.Git.EncryptedToken)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt git token: %w", err)
+ }
+ token = string(decrypted)
+ }
+
+ return git.NewGitRepository(ctx, r, git.RepositoryConfig{
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
- Token: r.Spec.Git.Token,
+ Token: token,
EncryptedToken: r.Spec.Git.EncryptedToken,
})
case provisioning.GitHubRepositoryType:
- cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
- return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets)
- }
-
- apiRepo, err := repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, cloneFn)
- if err != nil {
- return nil, fmt.Errorf("create github API repository: %w", err)
- }
-
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
- if !b.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
- logger.Debug("Instantiating Github repository with go-git and Github API")
- return apiRepo, nil
- }
-
- logger.Info("Instantiating Github repository with nanogit")
+ logger.Info("Instantiating Github repository")
ghCfg := r.Spec.GitHub
if ghCfg == nil {
return nil, fmt.Errorf("github configuration is required for nano git")
}
- gitCfg := nanogit.RepositoryConfig{
+ // Decrypt GitHub token if needed
+ ghToken := ghCfg.Token
+ if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
+ decrypted, err := b.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt github token: %w", err)
+ }
+ ghToken = string(decrypted)
+ }
+
+ gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
- Token: ghCfg.Token,
+ Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
}
- nanogitRepo, err := nanogit.NewGitRepository(ctx, b.secrets, r, gitCfg)
+ gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
- return nil, fmt.Errorf("error creating nanogit repository: %w", err)
+ return nil, fmt.Errorf("error creating git repository: %w", err)
}
- return nanogit.NewGithubRepository(apiRepo, nanogitRepo), nil
+ ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken)
+ if err != nil {
+ return nil, fmt.Errorf("error creating github repository: %w", err)
+ }
+
+ return ghRepo, nil
default:
return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
}
diff --git a/pkg/registry/apis/provisioning/repository/clonable_repository_mock.go b/pkg/registry/apis/provisioning/repository/clonable_repository_mock.go
deleted file mode 100644
index 41e08fd0fb9..00000000000
--- a/pkg/registry/apis/provisioning/repository/clonable_repository_mock.go
+++ /dev/null
@@ -1,95 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package repository
-
-import (
- context "context"
-
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockClonableRepository is an autogenerated mock type for the ClonableRepository type
-type MockClonableRepository struct {
- mock.Mock
-}
-
-type MockClonableRepository_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockClonableRepository) EXPECT() *MockClonableRepository_Expecter {
- return &MockClonableRepository_Expecter{mock: &_m.Mock}
-}
-
-// Clone provides a mock function with given fields: ctx, opts
-func (_m *MockClonableRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- ret := _m.Called(ctx, opts)
-
- if len(ret) == 0 {
- panic("no return value specified for Clone")
- }
-
- var r0 ClonedRepository
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
- return rf(ctx, opts)
- }
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
- r0 = rf(ctx, opts)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(ClonedRepository)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
- r1 = rf(ctx, opts)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockClonableRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
-type MockClonableRepository_Clone_Call struct {
- *mock.Call
-}
-
-// Clone is a helper method to define mock.On call
-// - ctx context.Context
-// - opts CloneOptions
-func (_e *MockClonableRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockClonableRepository_Clone_Call {
- return &MockClonableRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
-}
-
-func (_c *MockClonableRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockClonableRepository_Clone_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(CloneOptions))
- })
- return _c
-}
-
-func (_c *MockClonableRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockClonableRepository_Clone_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockClonableRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockClonableRepository_Clone_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockClonableRepository creates a new instance of MockClonableRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockClonableRepository(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockClonableRepository {
- mock := &MockClonableRepository{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/repository/clone.go b/pkg/registry/apis/provisioning/repository/clone.go
deleted file mode 100644
index d7224a23631..00000000000
--- a/pkg/registry/apis/provisioning/repository/clone.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package repository
-
-import (
- context "context"
- "fmt"
-
- "github.com/grafana/grafana-app-sdk/logging"
-)
-
-// WrapWithCloneAndPushIfPossible clones a repository if possible, executes operations on the clone,
-// and automatically pushes changes when the function completes. For repositories that support cloning,
-// all operations are transparently executed on the clone, and the clone is automatically cleaned up
-// afterward. If cloning is not supported, the original repository instance is used directly.
-func WrapWithCloneAndPushIfPossible(
- ctx context.Context,
- repo Repository,
- cloneOptions CloneOptions,
- pushOptions PushOptions,
- fn func(repo Repository, cloned bool) error,
-) error {
- clonable, ok := repo.(ClonableRepository)
- if !ok {
- return fn(repo, false)
- }
-
- clone, err := clonable.Clone(ctx, cloneOptions)
- if err != nil {
- return fmt.Errorf("clone repository: %w", err)
- }
-
- // We don't, we simply log it
- // FIXME: should we handle this differently?
- defer func() {
- if err := clone.Remove(ctx); err != nil {
- logging.FromContext(ctx).Error("failed to remove cloned repository after export", "err", err)
- }
- }()
-
- if err := fn(clone, true); err != nil {
- return err
- }
-
- return clone.Push(ctx, pushOptions)
-}
diff --git a/pkg/registry/apis/provisioning/repository/clone_fn_mock.go b/pkg/registry/apis/provisioning/repository/clone_fn_mock.go
deleted file mode 100644
index 65ab4db119e..00000000000
--- a/pkg/registry/apis/provisioning/repository/clone_fn_mock.go
+++ /dev/null
@@ -1,95 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package repository
-
-import (
- context "context"
-
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockCloneFn is an autogenerated mock type for the CloneFn type
-type MockCloneFn struct {
- mock.Mock
-}
-
-type MockCloneFn_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockCloneFn) EXPECT() *MockCloneFn_Expecter {
- return &MockCloneFn_Expecter{mock: &_m.Mock}
-}
-
-// Execute provides a mock function with given fields: ctx, opts
-func (_m *MockCloneFn) Execute(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- ret := _m.Called(ctx, opts)
-
- if len(ret) == 0 {
- panic("no return value specified for Execute")
- }
-
- var r0 ClonedRepository
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
- return rf(ctx, opts)
- }
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
- r0 = rf(ctx, opts)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(ClonedRepository)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
- r1 = rf(ctx, opts)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
-type MockCloneFn_Execute_Call struct {
- *mock.Call
-}
-
-// Execute is a helper method to define mock.On call
-// - ctx context.Context
-// - opts CloneOptions
-func (_e *MockCloneFn_Expecter) Execute(ctx interface{}, opts interface{}) *MockCloneFn_Execute_Call {
- return &MockCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, opts)}
-}
-
-func (_c *MockCloneFn_Execute_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockCloneFn_Execute_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(CloneOptions))
- })
- return _c
-}
-
-func (_c *MockCloneFn_Execute_Call) Return(_a0 ClonedRepository, _a1 error) *MockCloneFn_Execute_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockCloneFn_Execute_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockCloneFn_Execute_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockCloneFn creates a new instance of MockCloneFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockCloneFn(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockCloneFn {
- mock := &MockCloneFn{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/repository/clone_test.go b/pkg/registry/apis/provisioning/repository/clone_test.go
deleted file mode 100644
index ac685386aac..00000000000
--- a/pkg/registry/apis/provisioning/repository/clone_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
- "testing"
-
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
-)
-
-type mockClonableRepo struct {
- *MockClonableRepository
- *MockClonedRepository
-}
-
-func Test_WrapWithCloneAndPushIfPossible_NonClonableRepository(t *testing.T) {
- nonClonable := NewMockRepository(t)
- var called bool
- fn := func(repo Repository, cloned bool) error {
- called = true
- return errors.New("operation failed")
- }
-
- err := WrapWithCloneAndPushIfPossible(context.Background(), nonClonable, CloneOptions{}, PushOptions{}, fn)
- require.EqualError(t, err, "operation failed")
- require.True(t, called)
-}
-
-func TestWrapWithCloneAndPushIfPossible(t *testing.T) {
- tests := []struct {
- name string
- setupMocks func(t *testing.T) *mockClonableRepo
- operation func(repo Repository, cloned bool) error
- expectedError string
- }{
- {
- name: "successful clone, operation, and push",
- setupMocks: func(t *testing.T) *mockClonableRepo {
- mockRepo := NewMockClonableRepository(t)
- mockCloned := NewMockClonedRepository(t)
-
- mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
- mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(nil)
- mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
- return &mockClonableRepo{
- MockClonableRepository: mockRepo,
- MockClonedRepository: mockCloned,
- }
- },
- operation: func(repo Repository, cloned bool) error {
- require.True(t, cloned)
- return nil
- },
- },
- {
- name: "clone failure",
- setupMocks: func(t *testing.T) *mockClonableRepo {
- mockRepo := NewMockClonableRepository(t)
-
- mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(nil, errors.New("clone failed"))
-
- return &mockClonableRepo{
- MockClonableRepository: mockRepo,
- }
- },
- operation: func(repo Repository, cloned bool) error {
- return nil
- },
- expectedError: "clone repository: clone failed",
- },
- {
- name: "operation failure",
- setupMocks: func(t *testing.T) *mockClonableRepo {
- mockRepo := NewMockClonableRepository(t)
- mockCloned := NewMockClonedRepository(t)
-
- mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
- mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
-
- return &mockClonableRepo{
- MockClonableRepository: mockRepo,
- MockClonedRepository: mockCloned,
- }
- },
- operation: func(repo Repository, cloned bool) error {
- return errors.New("operation failed")
- },
- expectedError: "operation failed",
- },
- {
- name: "push failure",
- setupMocks: func(t *testing.T) *mockClonableRepo {
- mockRepo := NewMockClonableRepository(t)
- mockCloned := NewMockClonedRepository(t)
-
- mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
- mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(errors.New("push failed"))
- mockCloned.EXPECT().Remove(mock.Anything).Return(nil)
-
- return &mockClonableRepo{
- MockClonableRepository: mockRepo,
- MockClonedRepository: mockCloned,
- }
- },
- operation: func(repo Repository, cloned bool) error {
- return nil
- },
- expectedError: "push failed",
- },
- {
- name: "remove failure should only log",
- setupMocks: func(t *testing.T) *mockClonableRepo {
- mockRepo := NewMockClonableRepository(t)
- mockCloned := NewMockClonedRepository(t)
-
- mockRepo.EXPECT().Clone(mock.Anything, CloneOptions{}).Return(mockCloned, nil)
- mockCloned.EXPECT().Push(mock.Anything, PushOptions{}).Return(nil)
- mockCloned.EXPECT().Remove(mock.Anything).Return(errors.New("remove failed"))
-
- return &mockClonableRepo{
- MockClonableRepository: mockRepo,
- MockClonedRepository: mockCloned,
- }
- },
- operation: func(repo Repository, cloned bool) error {
- return nil
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- repo := tt.setupMocks(t)
- err := WrapWithCloneAndPushIfPossible(context.Background(), repo, CloneOptions{}, PushOptions{}, tt.operation)
-
- if tt.expectedError != "" {
- require.EqualError(t, err, tt.expectedError)
- } else {
- require.NoError(t, err)
- }
- })
- }
-}
diff --git a/pkg/registry/apis/provisioning/repository/config_repository_mock.go b/pkg/registry/apis/provisioning/repository/config_repository_mock.go
index 6c3ce61dc06..40ea3dcd3de 100644
--- a/pkg/registry/apis/provisioning/repository/config_repository_mock.go
+++ b/pkg/registry/apis/provisioning/repository/config_repository_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
diff --git a/pkg/registry/apis/provisioning/repository/git/branch.go b/pkg/registry/apis/provisioning/repository/git/branch.go
new file mode 100644
index 00000000000..28d4ec7b84c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/git/branch.go
@@ -0,0 +1,33 @@
+package git
+
+import (
+ "regexp"
+ "strings"
+)
+
+// basicGitBranchNameRegex is a regular expression to validate a git branch name
+// it does not cover all cases as positive lookaheads are not supported in Go's regexp
+var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
+
+// IsValidGitBranchName checks if a branch name is valid.
+// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
+// 1. The branch name must have at least one character and must not be empty.
+// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
+// 3. The branch name cannot contain consecutive slashes (`//`).
+// 4. The branch name cannot contain consecutive dots (`..`).
+// 5. The branch name cannot contain `@{`.
+// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
+func IsValidGitBranchName(branch string) bool {
+ if !basicGitBranchNameRegex.MatchString(branch) {
+ return false
+ }
+
+ // Additional checks for invalid patterns
+ if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
+ strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
+ strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
+ return false
+ }
+
+ return true
+}
diff --git a/pkg/registry/apis/provisioning/repository/git/branch_test.go b/pkg/registry/apis/provisioning/repository/git/branch_test.go
new file mode 100644
index 00000000000..fd6d3259a81
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/git/branch_test.go
@@ -0,0 +1,47 @@
+package git
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsValidGitBranchName(t *testing.T) {
+ tests := []struct {
+ name string
+ branch string
+ expected bool
+ }{
+ {"Valid branch name", "feature/add-tests", true},
+ {"Valid branch name with numbers", "feature/123-add-tests", true},
+ {"Valid branch name with dots", "feature.add.tests", true},
+ {"Valid branch name with hyphens", "feature-add-tests", true},
+ {"Valid branch name with underscores", "feature_add_tests", true},
+ {"Valid branch name with mixed characters", "feature/add_tests-123", true},
+ {"Starts with /", "/feature", false},
+ {"Ends with /", "feature/", false},
+ {"Ends with .", "feature.", false},
+ {"Ends with space", "feature ", false},
+ {"Contains consecutive slashes", "feature//branch", false},
+ {"Contains consecutive dots", "feature..branch", false},
+ {"Contains @{", "feature@{branch", false},
+ {"Contains invalid character ~", "feature~branch", false},
+ {"Contains invalid character ^", "feature^branch", false},
+ {"Contains invalid character :", "feature:branch", false},
+ {"Contains invalid character ?", "feature?branch", false},
+ {"Contains invalid character *", "feature*branch", false},
+ {"Contains invalid character [", "feature[branch", false},
+ {"Contains invalid character ]", "feature]branch", false},
+ {"Contains invalid character \\", "feature\\branch", false},
+ {"Empty branch name", "", false},
+ {"Only whitespace", " ", false},
+ {"Single valid character", "a", true},
+ {"Ends with .lock", "feature.lock", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, IsValidGitBranchName(tt.branch))
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/repository/git.go b/pkg/registry/apis/provisioning/repository/git/git.go
similarity index 60%
rename from pkg/registry/apis/provisioning/repository/git.go
rename to pkg/registry/apis/provisioning/repository/git/git.go
index 6a4244e76da..02c0f70dc9b 100644
--- a/pkg/registry/apis/provisioning/repository/git.go
+++ b/pkg/registry/apis/provisioning/repository/git/git.go
@@ -1,15 +1,17 @@
-package repository
+package git
+
+import "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
// GitRepository is an interface that combines all repository capabilities
// needed for Git repositories.
//
//go:generate mockery --name GitRepository --structname MockGitRepository --inpackage --filename git_repository_mock.go --with-expecter
type GitRepository interface {
- Repository
- Versioned
- Writer
- Reader
- ClonableRepository
+ repository.Repository
+ repository.Versioned
+ repository.Writer
+ repository.Reader
+ repository.StageableRepository
URL() string
Branch() string
}
diff --git a/pkg/registry/apis/provisioning/repository/git_repository_mock.go b/pkg/registry/apis/provisioning/repository/git/git_repository_mock.go
similarity index 87%
rename from pkg/registry/apis/provisioning/repository/git_repository_mock.go
rename to pkg/registry/apis/provisioning/repository/git/git_repository_mock.go
index a6ed5cb5046..4492595e98f 100644
--- a/pkg/registry/apis/provisioning/repository/git_repository_mock.go
+++ b/pkg/registry/apis/provisioning/repository/git/git_repository_mock.go
@@ -1,6 +1,6 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
-package repository
+package git
import (
context "context"
@@ -8,6 +8,8 @@ import (
mock "github.com/stretchr/testify/mock"
field "k8s.io/apimachinery/pkg/util/validation/field"
+ repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
@@ -69,83 +71,24 @@ func (_c *MockGitRepository_Branch_Call) RunAndReturn(run func() string) *MockGi
return _c
}
-// Clone provides a mock function with given fields: ctx, opts
-func (_m *MockGitRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- ret := _m.Called(ctx, opts)
-
- if len(ret) == 0 {
- panic("no return value specified for Clone")
- }
-
- var r0 ClonedRepository
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
- return rf(ctx, opts)
- }
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
- r0 = rf(ctx, opts)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(ClonedRepository)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
- r1 = rf(ctx, opts)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockGitRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
-type MockGitRepository_Clone_Call struct {
- *mock.Call
-}
-
-// Clone is a helper method to define mock.On call
-// - ctx context.Context
-// - opts CloneOptions
-func (_e *MockGitRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGitRepository_Clone_Call {
- return &MockGitRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
-}
-
-func (_c *MockGitRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGitRepository_Clone_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(CloneOptions))
- })
- return _c
-}
-
-func (_c *MockGitRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGitRepository_Clone_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockGitRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGitRepository_Clone_Call {
- _c.Call.Return(run)
- return _c
-}
-
// CompareFiles provides a mock function with given fields: ctx, base, ref
-func (_m *MockGitRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) {
+func (_m *MockGitRepository) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) {
ret := _m.Called(ctx, base, ref)
if len(ret) == 0 {
panic("no return value specified for CompareFiles")
}
- var r0 []VersionedFileChange
+ var r0 []repository.VersionedFileChange
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok {
return rf(ctx, base, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok {
r0 = rf(ctx, base, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).([]VersionedFileChange)
+ r0 = ret.Get(0).([]repository.VersionedFileChange)
}
}
@@ -178,12 +121,12 @@ func (_c *MockGitRepository_CompareFiles_Call) Run(run func(ctx context.Context,
return _c
}
-func (_c *MockGitRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGitRepository_CompareFiles_Call {
+func (_c *MockGitRepository_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockGitRepository_CompareFiles_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGitRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGitRepository_CompareFiles_Call {
+func (_c *MockGitRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockGitRepository_CompareFiles_Call {
_c.Call.Return(run)
return _c
}
@@ -451,23 +394,23 @@ func (_c *MockGitRepository_LatestRef_Call) RunAndReturn(run func(context.Contex
}
// Read provides a mock function with given fields: ctx, path, ref
-func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
+func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
panic("no return value specified for Read")
}
- var r0 *FileInfo
+ var r0 *repository.FileInfo
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok {
return rf(ctx, path, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok {
r0 = rf(ctx, path, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).(*FileInfo)
+ r0 = ret.Get(0).(*repository.FileInfo)
}
}
@@ -500,34 +443,34 @@ func (_c *MockGitRepository_Read_Call) Run(run func(ctx context.Context, path st
return _c
}
-func (_c *MockGitRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGitRepository_Read_Call {
+func (_c *MockGitRepository_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockGitRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGitRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGitRepository_Read_Call {
+func (_c *MockGitRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockGitRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
-func (_m *MockGitRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+func (_m *MockGitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
panic("no return value specified for ReadTree")
}
- var r0 []FileTreeEntry
+ var r0 []repository.FileTreeEntry
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string) ([]repository.FileTreeEntry, error)); ok {
return rf(ctx, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string) []repository.FileTreeEntry); ok {
r0 = rf(ctx, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).([]FileTreeEntry)
+ r0 = ret.Get(0).([]repository.FileTreeEntry)
}
}
@@ -559,12 +502,71 @@ func (_c *MockGitRepository_ReadTree_Call) Run(run func(ctx context.Context, ref
return _c
}
-func (_c *MockGitRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGitRepository_ReadTree_Call {
+func (_c *MockGitRepository_ReadTree_Call) Return(_a0 []repository.FileTreeEntry, _a1 error) *MockGitRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGitRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGitRepository_ReadTree_Call {
+func (_c *MockGitRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]repository.FileTreeEntry, error)) *MockGitRepository_ReadTree_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Stage provides a mock function with given fields: ctx, opts
+func (_m *MockGitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
+ ret := _m.Called(ctx, opts)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Stage")
+ }
+
+ var r0 repository.StagedRepository
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) (repository.StagedRepository, error)); ok {
+ return rf(ctx, opts)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) repository.StagedRepository); ok {
+ r0 = rf(ctx, opts)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(repository.StagedRepository)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, repository.StageOptions) error); ok {
+ r1 = rf(ctx, opts)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockGitRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
+type MockGitRepository_Stage_Call struct {
+ *mock.Call
+}
+
+// Stage is a helper method to define mock.On call
+// - ctx context.Context
+// - opts repository.StageOptions
+func (_e *MockGitRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockGitRepository_Stage_Call {
+ return &MockGitRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
+}
+
+func (_c *MockGitRepository_Stage_Call) Run(run func(ctx context.Context, opts repository.StageOptions)) *MockGitRepository_Stage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(repository.StageOptions))
+ })
+ return _c
+}
+
+func (_c *MockGitRepository_Stage_Call) Return(_a0 repository.StagedRepository, _a1 error) *MockGitRepository_Stage_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockGitRepository_Stage_Call) RunAndReturn(run func(context.Context, repository.StageOptions) (repository.StagedRepository, error)) *MockGitRepository_Stage_Call {
_c.Call.Return(run)
return _c
}
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git.go b/pkg/registry/apis/provisioning/repository/git/repository.go
similarity index 94%
rename from pkg/registry/apis/provisioning/repository/nanogit/git.go
rename to pkg/registry/apis/provisioning/repository/git/repository.go
index 4ee0f0c6fa5..7d0d4d518f1 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/git.go
+++ b/pkg/registry/apis/provisioning/repository/git/repository.go
@@ -1,6 +1,7 @@
-package nanogit
+package git
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -18,7 +19,6 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/options"
@@ -39,28 +39,19 @@ type gitRepository struct {
config *provisioning.Repository
gitConfig RepositoryConfig
client nanogit.Client
- secrets secrets.Service
}
func NewGitRepository(
ctx context.Context,
- secrets secrets.Service,
config *provisioning.Repository,
gitConfig RepositoryConfig,
-) (repository.GitRepository, error) {
- if gitConfig.Token == "" {
- decrypted, err := secrets.Decrypt(ctx, gitConfig.EncryptedToken)
- if err != nil {
- return nil, fmt.Errorf("decrypt token: %w", err)
- }
- gitConfig.Token = string(decrypted)
+) (GitRepository, error) {
+ var opts []options.Option
+ if len(gitConfig.Token) > 0 {
+ opts = append(opts, options.WithBasicAuth("git", gitConfig.Token))
}
- // Create nanogit client with authentication
- client, err := nanogit.NewHTTPClient(
- gitConfig.URL,
- options.WithBasicAuth("git", gitConfig.Token),
- )
+ client, err := nanogit.NewHTTPClient(gitConfig.URL, opts...)
if err != nil {
return nil, fmt.Errorf("create nanogit client: %w", err)
}
@@ -69,7 +60,6 @@ func NewGitRepository(
config: config,
gitConfig: gitConfig,
client: client,
- secrets: secrets,
}, nil
}
@@ -99,12 +89,15 @@ func (r *gitRepository) Validate() (list field.ErrorList) {
}
if cfg.Branch == "" {
list = append(list, field.Required(field.NewPath("spec", t, "branch"), "a git branch is required"))
- } else if !repository.IsValidGitBranchName(cfg.Branch) {
+ } else if !IsValidGitBranchName(cfg.Branch) {
list = append(list, field.Invalid(field.NewPath("spec", t, "branch"), cfg.Branch, "invalid branch name"))
}
- if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
- list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
+ // If the repository has workflows, we require a token or encrypted token
+ if len(r.config.Spec.Workflows) > 0 {
+ if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
+ list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
+ }
}
if err := safepath.IsSafe(cfg.Path); err != nil {
@@ -413,11 +406,15 @@ func (r *gitRepository) Write(ctx context.Context, path string, ref string, data
}
ctx, _ = r.logger(ctx, ref)
- _, err := r.Read(ctx, path, ref)
+ info, err := r.Read(ctx, path, ref)
if err != nil && !(errors.Is(err, repository.ErrFileNotFound)) {
return fmt.Errorf("check if file exists before writing: %w", err)
}
if err == nil {
+ // If the value already exists and is the same, we don't need to do anything
+ if bytes.Equal(info.Data, data) {
+ return nil
+ }
return r.Update(ctx, path, ref, data, message)
}
@@ -584,7 +581,7 @@ func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]r
return changes, nil
}
-func (r *gitRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
+func (r *gitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
return NewStagedGitRepository(ctx, r, opts)
}
@@ -592,7 +589,7 @@ func (r *gitRepository) Clone(ctx context.Context, opts repository.CloneOptions)
func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.Hash, error) {
// Use default branch if ref is empty
if ref == "" {
- ref = fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch)
+ ref = r.gitConfig.Branch
}
// Try to parse ref as a hash first
@@ -602,6 +599,9 @@ func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.
return refHash, nil
}
+ // Prefix ref with refs/heads/
+ ref = fmt.Sprintf("refs/heads/%s", ref)
+
// Not a valid hash, try to resolve as a branch reference
branchRef, err := r.client.GetRef(ctx, ref)
if err != nil {
@@ -617,7 +617,7 @@ func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.
// ensureBranchExists checks if a branch exists and creates it if it doesn't,
// returning the branch reference to avoid duplicate GetRef calls
func (r *gitRepository) ensureBranchExists(ctx context.Context, branchName string) (nanogit.Ref, error) {
- if !repository.IsValidGitBranchName(branchName) {
+ if !IsValidGitBranchName(branchName) {
return nanogit.Ref{}, &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusBadRequest,
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git_test.go b/pkg/registry/apis/provisioning/repository/git/repository_test.go
similarity index 96%
rename from pkg/registry/apis/provisioning/repository/nanogit/git_test.go
rename to pkg/registry/apis/provisioning/repository/git/repository_test.go
index cfc4abdea06..999d1a74816 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/git_test.go
+++ b/pkg/registry/apis/provisioning/repository/git/repository_test.go
@@ -1,4 +1,4 @@
-package nanogit
+package git
import (
"context"
@@ -7,17 +7,17 @@ import (
"testing"
"time"
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
- "github.com/grafana/nanogit"
- "github.com/grafana/nanogit/mocks"
- "github.com/grafana/nanogit/protocol"
- "github.com/grafana/nanogit/protocol/hash"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/nanogit"
+ "github.com/grafana/nanogit/mocks"
+ "github.com/grafana/nanogit/protocol"
+ "github.com/grafana/nanogit/protocol/hash"
)
func TestGitRepository_Validate(t *testing.T) {
@@ -138,10 +138,11 @@ func TestGitRepository_Validate(t *testing.T) {
},
},
{
- name: "missing token",
+ name: "missing token for R/W repository",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
- Type: "test_type",
+ Type: "test_type",
+ Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
},
},
gitConfig: RepositoryConfig{
@@ -153,6 +154,21 @@ func TestGitRepository_Validate(t *testing.T) {
field.Required(field.NewPath("spec", "test_type", "token"), "a git access token is required"),
},
},
+ {
+ name: "missing token for read-only repository",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Type: "test_type",
+ Workflows: nil, // read-only
+ },
+ },
+ gitConfig: RepositoryConfig{
+ URL: "https://git.example.com/repo.git",
+ Branch: "main",
+ Token: "", // Empty token
+ },
+ want: nil,
+ },
{
name: "unsafe path",
config: &provisioning.Repository{
@@ -258,20 +274,8 @@ func TestIsValidGitURL(t *testing.T) {
}
}
-// Mock secrets service for testing
-type mockSecretsService struct{}
-
-func (m *mockSecretsService) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
- return []byte("decrypted-token"), nil
-}
-
-func (m *mockSecretsService) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
- return []byte("encrypted-token"), nil
-}
-
func TestNewGit(t *testing.T) {
ctx := context.Background()
- mockSecrets := &mockSecretsService{}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -288,7 +292,7 @@ func TestNewGit(t *testing.T) {
// This should succeed in creating the client but won't be able to connect
// We just test that the basic structure is created correctly
- gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
+ gitRepo, err := NewGitRepository(ctx, config, gitConfig)
require.NoError(t, err)
require.NotNil(t, gitRepo)
require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL())
@@ -1737,57 +1741,21 @@ func TestGitRepository_createSignature(t *testing.T) {
func TestNewGitRepository(t *testing.T) {
tests := []struct {
- name string
- setupMock func(*mockSecretsService)
- gitConfig RepositoryConfig
- wantError bool
- expectURL string
- expectToken string
+ name string
+ gitConfig RepositoryConfig
+ wantError bool
+ expectURL string
}{
{
name: "success - with token",
- setupMock: func(mockSecrets *mockSecretsService) {
- // No setup needed for token case
- },
gitConfig: RepositoryConfig{
URL: "https://git.example.com/owner/repo.git",
Branch: "main",
Token: "plain-token",
Path: "configs",
},
- wantError: false,
- expectURL: "https://git.example.com/owner/repo.git",
- expectToken: "plain-token",
- },
- {
- name: "success - with encrypted token",
- setupMock: func(mockSecrets *mockSecretsService) {
- // Mock will return decrypted token
- },
- gitConfig: RepositoryConfig{
- URL: "https://git.example.com/owner/repo.git",
- Branch: "main",
- Token: "", // Empty token, will use encrypted
- EncryptedToken: []byte("encrypted-token"),
- Path: "configs",
- },
- wantError: false,
- expectURL: "https://git.example.com/owner/repo.git",
- expectToken: "decrypted-token", // From mock
- },
- {
- name: "failure - decryption error",
- setupMock: func(mockSecrets *mockSecretsService) {
- // This test will use the separate mockSecretsServiceWithError
- },
- gitConfig: RepositoryConfig{
- URL: "https://git.example.com/owner/repo.git",
- Branch: "main",
- Token: "",
- EncryptedToken: []byte("bad-encrypted-token"),
- Path: "configs",
- },
- wantError: true,
+ wantError: false,
+ expectURL: "https://git.example.com/owner/repo.git",
},
}
@@ -1795,20 +1763,13 @@ func TestNewGitRepository(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
- var mockSecrets secrets.Service
- if tt.name == "failure - decryption error" {
- mockSecrets = &mockSecretsServiceWithError{shouldError: true}
- } else {
- mockSecrets = &mockSecretsService{}
- }
-
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
},
}
- gitRepo, err := NewGitRepository(ctx, mockSecrets, config, tt.gitConfig)
+ gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig)
if tt.wantError {
require.Error(t, err)
@@ -2176,7 +2137,7 @@ func TestGitRepository_logger(t *testing.T) {
})
}
-func TestGitRepository_Clone(t *testing.T) {
+func TestGitRepository_Stage(t *testing.T) {
gitRepo := &gitRepository{
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -2192,9 +2153,8 @@ func TestGitRepository_Clone(t *testing.T) {
t.Run("calls NewStagedGitRepository", func(t *testing.T) {
ctx := context.Background()
- opts := repository.CloneOptions{
- CreateIfNotExists: true,
- PushOnWrites: true,
+ opts := repository.StageOptions{
+ PushOnWrites: true,
}
// Since NewStagedGitRepository is not mocked and may panic, we expect this to fail
@@ -2206,11 +2166,11 @@ func TestGitRepository_Clone(t *testing.T) {
}
}()
- cloned, err := gitRepo.Clone(ctx, opts)
+ staged, err := gitRepo.Stage(ctx, opts)
// This will likely error/panic since we don't have a real implementation
// but we're testing that the method exists and forwards to NewStagedGitRepository
- _ = cloned
+ _ = staged
_ = err
})
}
@@ -2279,50 +2239,6 @@ func TestGitRepository_EdgeCases(t *testing.T) {
})
}
-// Enhanced secrets service mock with error handling
-type mockSecretsServiceWithError struct {
- shouldError bool
-}
-
-func (m *mockSecretsServiceWithError) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
- if m.shouldError {
- return nil, errors.New("decryption failed")
- }
- return []byte("decrypted-token"), nil
-}
-
-func (m *mockSecretsServiceWithError) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
- if m.shouldError {
- return nil, errors.New("encryption failed")
- }
- return []byte("encrypted-token"), nil
-}
-
-func TestNewGitRepository_DecryptionError(t *testing.T) {
- ctx := context.Background()
- mockSecrets := &mockSecretsServiceWithError{shouldError: true}
-
- config := &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- },
- }
-
- gitConfig := RepositoryConfig{
- URL: "https://git.example.com/owner/repo.git",
- Branch: "main",
- Token: "",
- EncryptedToken: []byte("encrypted-token"),
- Path: "configs",
- }
-
- gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
-
- require.Error(t, err)
- require.Nil(t, gitRepo)
- require.Contains(t, err.Error(), "decrypt token")
-}
-
func TestGitRepository_ValidateBranchNames(t *testing.T) {
tests := []struct {
name string
@@ -2790,7 +2706,6 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
// This test would require mocking nanogit.NewHTTPClient which is difficult
// We test the path where the client creation would fail by using invalid URL
ctx := context.Background()
- mockSecrets := &mockSecretsService{}
config := &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -2805,7 +2720,7 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
Path: "configs",
}
- gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig)
+ gitRepo, err := NewGitRepository(ctx, config, gitConfig)
// We expect this to fail during client creation
require.Error(t, err)
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/staged.go b/pkg/registry/apis/provisioning/repository/git/staged.go
similarity index 86%
rename from pkg/registry/apis/provisioning/repository/nanogit/staged.go
rename to pkg/registry/apis/provisioning/repository/git/staged.go
index d383e5b47e3..e11044230b2 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/staged.go
+++ b/pkg/registry/apis/provisioning/repository/git/staged.go
@@ -1,4 +1,4 @@
-package nanogit
+package git
import (
"context"
@@ -15,17 +15,11 @@ import (
// once that happens we could do more magic here.
type stagedGitRepository struct {
*gitRepository
- opts repository.CloneOptions
+ opts repository.StageOptions
writer nanogit.StagedWriter
}
-func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.CloneOptions) (repository.ClonedRepository, error) {
- if opts.BeforeFn != nil {
- if err := opts.BeforeFn(); err != nil {
- return nil, err
- }
- }
-
+func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.StageOptions) (repository.StagedRepository, error) {
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
@@ -89,28 +83,35 @@ func (r *stagedGitRepository) Create(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
- return r.Push(ctx, repository.PushOptions{})
+ return r.Push(ctx)
}
return nil
}
+func (r *stagedGitRepository) blobExists(ctx context.Context, path string) (bool, error) {
+ if r.gitConfig.Path != "" {
+ path = safepath.Join(r.gitConfig.Path, path)
+ }
+ return r.writer.BlobExists(ctx, path)
+}
+
func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data []byte, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
return errors.New("ref is not supported for staged repository")
}
- ok, err := r.writer.BlobExists(ctx, path)
+ exists, err := r.blobExists(ctx, path)
if err != nil {
return fmt.Errorf("check if file exists: %w", err)
}
- if !ok {
- if err := r.create(ctx, path, data, r.writer); err != nil {
+ if exists {
+ if err := r.update(ctx, path, data, r.writer); err != nil {
return err
}
} else {
- if err := r.update(ctx, path, data, r.writer); err != nil {
+ if err := r.create(ctx, path, data, r.writer); err != nil {
return err
}
}
@@ -120,7 +121,7 @@ func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
- return r.Push(ctx, repository.PushOptions{})
+ return r.Push(ctx)
}
return nil
@@ -144,7 +145,7 @@ func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data
}
if r.opts.PushOnWrites {
- return r.Push(ctx, repository.PushOptions{})
+ return r.Push(ctx)
}
return nil
@@ -164,22 +165,16 @@ func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message str
}
if r.opts.PushOnWrites {
- return r.Push(ctx, repository.PushOptions{})
+ return r.Push(ctx)
}
return nil
}
-func (r *stagedGitRepository) Push(ctx context.Context, opts repository.PushOptions) error {
- if opts.BeforeFn != nil {
- if err := opts.BeforeFn(); err != nil {
- return err
- }
- }
-
- if opts.Timeout > 0 {
+func (r *stagedGitRepository) Push(ctx context.Context) error {
+ if r.opts.Timeout > 0 {
var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
+ ctx, cancel = context.WithTimeout(ctx, r.opts.Timeout)
defer cancel()
}
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/staged_test.go b/pkg/registry/apis/provisioning/repository/git/staged_test.go
similarity index 88%
rename from pkg/registry/apis/provisioning/repository/nanogit/staged_test.go
rename to pkg/registry/apis/provisioning/repository/git/staged_test.go
index 77f63016a66..9dc502cf8c2 100644
--- a/pkg/registry/apis/provisioning/repository/nanogit/staged_test.go
+++ b/pkg/registry/apis/provisioning/repository/git/staged_test.go
@@ -1,4 +1,4 @@
-package nanogit
+package git
import (
"context"
@@ -18,7 +18,7 @@ func TestNewStagedGitRepository(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeClient)
- opts repository.CloneOptions
+ opts repository.StageOptions
wantError error
}{
{
@@ -31,9 +31,8 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
- opts: repository.CloneOptions{
- CreateIfNotExists: false,
- PushOnWrites: false,
+ opts: repository.StageOptions{
+ PushOnWrites: false,
},
wantError: nil,
},
@@ -47,12 +46,8 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
- opts: repository.CloneOptions{
- CreateIfNotExists: false,
- PushOnWrites: false,
- BeforeFn: func() error {
- return nil
- },
+ opts: repository.StageOptions{
+ PushOnWrites: false,
},
wantError: nil,
},
@@ -66,33 +61,19 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
- opts: repository.CloneOptions{
- CreateIfNotExists: false,
- PushOnWrites: false,
- Timeout: time.Second * 5,
+ opts: repository.StageOptions{
+ PushOnWrites: false,
+ Timeout: time.Second * 5,
},
wantError: nil,
},
- {
- name: "fails with BeforeFn error",
- setupMock: func(mockClient *mocks.FakeClient) {
- // No setup needed as BeforeFn fails first
- },
- opts: repository.CloneOptions{
- BeforeFn: func() error {
- return errors.New("before function failed")
- },
- },
- wantError: errors.New("before function failed"),
- },
{
name: "fails with GetRef error",
setupMock: func(mockClient *mocks.FakeClient) {
mockClient.GetRefReturns(nanogit.Ref{}, errors.New("ref not found"))
},
- opts: repository.CloneOptions{
- CreateIfNotExists: false,
- PushOnWrites: false,
+ opts: repository.StageOptions{
+ PushOnWrites: false,
},
wantError: errors.New("ref not found"),
},
@@ -105,9 +86,8 @@ func TestNewStagedGitRepository(t *testing.T) {
}, nil)
mockClient.NewStagedWriterReturns(nil, errors.New("failed to create writer"))
},
- opts: repository.CloneOptions{
- CreateIfNotExists: false,
- PushOnWrites: false,
+ opts: repository.StageOptions{
+ PushOnWrites: false,
},
wantError: errors.New("build staged writer: failed to create writer"),
},
@@ -141,13 +121,8 @@ func TestNewStagedGitRepository(t *testing.T) {
// Compare opts fields individually since function pointers can't be compared directly
actualOpts := stagedRepo.(*stagedGitRepository).opts
- require.Equal(t, tt.opts.CreateIfNotExists, actualOpts.CreateIfNotExists)
require.Equal(t, tt.opts.PushOnWrites, actualOpts.PushOnWrites)
- require.Equal(t, tt.opts.MaxSize, actualOpts.MaxSize)
require.Equal(t, tt.opts.Timeout, actualOpts.Timeout)
- require.Equal(t, tt.opts.Progress, actualOpts.Progress)
- // BeforeFn is a function pointer, so we just check if both are nil or both are not nil
- require.Equal(t, tt.opts.BeforeFn == nil, actualOpts.BeforeFn == nil)
}
})
}
@@ -287,7 +262,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
- opts repository.CloneOptions
+ opts repository.StageOptions
path string
ref string
data []byte
@@ -301,7 +276,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -318,7 +293,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -333,7 +308,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -347,7 +322,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create blob failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -362,7 +337,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -378,7 +353,7 @@ func TestStagedGitRepository_Create(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(errors.New("push failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -419,7 +394,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
- opts repository.CloneOptions
+ opts repository.StageOptions
path string
ref string
data []byte
@@ -435,7 +410,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -454,7 +429,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -470,7 +445,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -484,7 +459,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.BlobExistsReturns(false, errors.New("blob exists check failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -499,7 +474,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.BlobExistsReturns(false, nil)
mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -514,7 +489,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.BlobExistsReturns(true, nil)
mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -530,7 +505,7 @@ func TestStagedGitRepository_Write(t *testing.T) {
mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -570,7 +545,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
- opts repository.CloneOptions
+ opts repository.StageOptions
path string
ref string
data []byte
@@ -584,7 +559,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -601,7 +576,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: true,
},
path: "test.yaml",
@@ -616,7 +591,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -630,7 +605,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "directory/",
@@ -644,7 +619,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update blob failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -659,7 +634,7 @@ func TestStagedGitRepository_Update(t *testing.T) {
mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -699,7 +674,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
- opts repository.CloneOptions
+ opts repository.StageOptions
path string
ref string
message string
@@ -712,7 +687,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -728,7 +703,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.CommitReturns(&nanogit.Commit{}, nil)
mockWriter.PushReturns(nil)
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: true,
},
path: "testdir/",
@@ -742,7 +717,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
// No setup needed as error occurs before writer calls
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -755,7 +730,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.DeleteBlobReturns(hash.Hash{}, errors.New("delete blob failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -769,7 +744,7 @@ func TestStagedGitRepository_Delete(t *testing.T) {
mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil)
mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed"))
},
- opts: repository.CloneOptions{
+ opts: repository.StageOptions{
PushOnWrites: false,
},
path: "test.yaml",
@@ -808,7 +783,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeStagedWriter)
- opts repository.PushOptions
wantError error
expectCalls int
}{
@@ -817,7 +791,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
- opts: repository.PushOptions{},
wantError: nil,
expectCalls: 1,
},
@@ -826,11 +799,6 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
- opts: repository.PushOptions{
- BeforeFn: func() error {
- return nil
- },
- },
wantError: nil,
expectCalls: 1,
},
@@ -839,31 +807,14 @@ func TestStagedGitRepository_Push(t *testing.T) {
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(nil)
},
- opts: repository.PushOptions{
- Timeout: time.Second * 5,
- },
wantError: nil,
expectCalls: 1,
},
- {
- name: "fails with before fn error",
- setupMock: func(_ *mocks.FakeStagedWriter) {
- // No setup needed as BeforeFn fails first
- },
- opts: repository.PushOptions{
- BeforeFn: func() error {
- return errors.New("before function failed")
- },
- },
- wantError: errors.New("before function failed"),
- expectCalls: 0,
- },
{
name: "fails with push error",
setupMock: func(mockWriter *mocks.FakeStagedWriter) {
mockWriter.PushReturns(errors.New("push failed"))
},
- opts: repository.PushOptions{},
wantError: errors.New("push failed"),
expectCalls: 1,
},
@@ -874,9 +825,9 @@ func TestStagedGitRepository_Push(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
tt.setupMock(mockWriter)
- stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{})
+ stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{})
- err := stagedRepo.Push(context.Background(), tt.opts)
+ err := stagedRepo.Push(context.Background())
if tt.wantError != nil {
require.EqualError(t, err, tt.wantError.Error())
@@ -892,7 +843,7 @@ func TestStagedGitRepository_Push(t *testing.T) {
func TestStagedGitRepository_Remove(t *testing.T) {
t.Run("succeeds with remove", func(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
- stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{})
+ stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{})
err := stagedRepo.Remove(context.Background())
require.NoError(t, err)
@@ -904,10 +855,10 @@ func TestStagedGitRepository_Remove(t *testing.T) {
func createTestStagedRepository(mockClient *mocks.FakeClient) *stagedGitRepository {
mockWriter := &mocks.FakeStagedWriter{}
- return createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{}, mockClient)
+ return createTestStagedRepositoryWithWriter(mockWriter, repository.StageOptions{}, mockClient)
}
-func createTestStagedRepositoryWithWriter(mockWriter *mocks.FakeStagedWriter, opts repository.CloneOptions, mockClient ...*mocks.FakeClient) *stagedGitRepository {
+func createTestStagedRepositoryWithWriter(mockWriter *mocks.FakeStagedWriter, opts repository.StageOptions, mockClient ...*mocks.FakeClient) *stagedGitRepository {
var client nanogit.Client
if len(mockClient) > 0 {
client = mockClient[0]
diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go
deleted file mode 100644
index 42c78e55c8e..00000000000
--- a/pkg/registry/apis/provisioning/repository/github.go
+++ /dev/null
@@ -1,683 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "net/url"
- "regexp"
- "strings"
-
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/util/validation/field"
-
- "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"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
-)
-
-// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
-type githubRepository struct {
- config *provisioning.Repository
- gh pgh.Client // assumes github.com base URL
- secrets secrets.Service
-
- owner string
- repo string
-
- cloneFn CloneFn
-}
-
-// GithubRepository is an interface that combines all repository capabilities
-// needed for GitHub repositories.
-
-//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter
-type GithubRepository interface {
- Repository
- Versioned
- Writer
- Reader
- RepositoryWithURLs
- ClonableRepository
- Owner() string
- Repo() string
- Client() pgh.Client
-}
-
-func NewGitHub(
- ctx context.Context,
- config *provisioning.Repository,
- factory *pgh.Factory,
- secrets secrets.Service,
- cloneFn CloneFn,
-) (GithubRepository, error) {
- owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
- if err != nil {
- return nil, fmt.Errorf("parse owner and repo: %w", err)
- }
-
- token := config.Spec.GitHub.Token
- if token == "" {
- decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
- if err != nil {
- return nil, fmt.Errorf("decrypt token: %w", err)
- }
- token = string(decrypted)
- }
-
- return &githubRepository{
- config: config,
- gh: factory.New(ctx, token), // TODO, baseURL from config
- secrets: secrets,
- owner: owner,
- repo: repo,
- cloneFn: cloneFn,
- }, nil
-}
-
-func (r *githubRepository) Config() *provisioning.Repository {
- return r.config
-}
-
-func (r *githubRepository) Owner() string {
- return r.owner
-}
-
-func (r *githubRepository) Repo() string {
- return r.repo
-}
-
-func (r *githubRepository) Client() pgh.Client {
- return r.gh
-}
-
-// Validate implements provisioning.Repository.
-func (r *githubRepository) Validate() (list field.ErrorList) {
- gh := r.config.Spec.GitHub
- if gh == nil {
- list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
- return list
- }
- if gh.URL == "" {
- list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
- } else {
- _, _, err := ParseOwnerRepoGithub(gh.URL)
- if err != nil {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
- } else if !strings.HasPrefix(gh.URL, "https://github.com/") {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
- }
- }
- if gh.Branch == "" {
- list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required"))
- } else if !IsValidGitBranchName(gh.Branch) {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name"))
- }
- // TODO: Use two fields for token
- if gh.Token == "" && len(gh.EncryptedToken) == 0 {
- list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
- }
-
- if err := safepath.IsSafe(gh.Path); err != nil {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, err.Error()))
- }
-
- if safepath.IsAbs(gh.Path) {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, "path must be relative"))
- }
-
- return list
-}
-
-func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) {
- parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git"))
- if e != nil {
- err = e
- return
- }
- parts := strings.Split(parsed.Path, "/")
- if len(parts) < 3 {
- err = fmt.Errorf("unable to parse repo+owner from url")
- return
- }
- return parts[1], parts[2], nil
-}
-
-// Test implements provisioning.Repository.
-func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
- if err := r.gh.IsAuthenticated(ctx); err != 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
- }
-
- url := r.config.Spec.GitHub.URL
- owner, repo, err := ParseOwnerRepoGithub(url)
- if err != 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 fromFieldError(field.Invalid(
- field.NewPath("spec", "github", "url"), url, err.Error())), nil
- }
-
- if !ok {
- return fromFieldError(field.NotFound(
- field.NewPath("spec", "github", "url"), url)), nil
- }
-
- branch := r.config.Spec.GitHub.Branch
- ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, branch)
- if err != nil {
- return fromFieldError(field.Invalid(
- field.NewPath("spec", "github", "branch"), branch, err.Error())), nil
- }
-
- if !ok {
- return fromFieldError(field.NotFound(
- field.NewPath("spec", "github", "branch"), branch)), nil
- }
-
- return &provisioning.TestResults{
- Code: http.StatusOK,
- Success: true,
- }, nil
-}
-
-// ReadResource implements provisioning.Repository.
-func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*FileInfo, error) {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
-
- finalPath := safepath.Join(r.config.Spec.GitHub.Path, filePath)
- content, dirContent, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
- if err != nil {
- if errors.Is(err, pgh.ErrResourceNotFound) {
- return nil, ErrFileNotFound
- }
-
- return nil, fmt.Errorf("get contents: %w", err)
- }
- if dirContent != nil {
- return &FileInfo{
- Path: filePath,
- Ref: ref,
- }, nil
- }
-
- data, err := content.GetFileContent()
- if err != nil {
- return nil, fmt.Errorf("get content: %w", err)
- }
- return &FileInfo{
- Path: filePath,
- Ref: ref,
- Data: []byte(data),
- Hash: content.GetSHA(),
- }, nil
-}
-
-func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
-
- ctx, _ = r.logger(ctx, ref)
- tree, truncated, err := r.gh.GetTree(ctx, r.owner, r.repo, r.config.Spec.GitHub.Path, ref, true)
- if err != nil {
- if errors.Is(err, pgh.ErrResourceNotFound) {
- return nil, &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: fmt.Sprintf("tree not found; ref=%s", ref),
- Code: http.StatusNotFound,
- },
- }
- }
- return nil, fmt.Errorf("get tree: %w", err)
- }
-
- if truncated {
- return nil, fmt.Errorf("tree truncated")
- }
-
- entries := make([]FileTreeEntry, 0, len(tree))
- for _, entry := range tree {
- isBlob := !entry.IsDirectory()
- // FIXME: this we could potentially do somewhere else on in a different way
- filePath := entry.GetPath()
- if !isBlob && !safepath.IsDir(filePath) {
- filePath = filePath + "/"
- }
-
- converted := FileTreeEntry{
- Path: filePath,
- Size: entry.GetSize(),
- Hash: entry.GetSHA(),
- Blob: !entry.IsDirectory(),
- }
- entries = append(entries, converted)
- }
- return entries, nil
-}
-
-func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
- ctx, _ = r.logger(ctx, ref)
-
- if err := r.ensureBranchExists(ctx, ref); err != nil {
- return err
- }
-
- finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
-
- // Create .keep file if it is a directory
- if safepath.IsDir(finalPath) {
- if data != nil {
- return apierrors.NewBadRequest("data cannot be provided for a directory")
- }
-
- finalPath = safepath.Join(finalPath, ".keep")
- data = []byte{}
- }
-
- err := r.gh.CreateFile(ctx, r.owner, r.repo, finalPath, ref, comment, data)
- if errors.Is(err, pgh.ErrResourceAlreadyExists) {
- return &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "file already exists",
- Code: http.StatusConflict,
- },
- }
- }
-
- return err
-}
-
-func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
- ctx, _ = r.logger(ctx, ref)
-
- if err := r.ensureBranchExists(ctx, ref); err != nil {
- return err
- }
-
- finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
- file, _, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
- if err != nil {
- if errors.Is(err, pgh.ErrResourceNotFound) {
- return &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "file not found",
- Code: http.StatusNotFound,
- },
- }
- }
-
- return fmt.Errorf("get content before file update: %w", err)
- }
- if file.IsDirectory() {
- return apierrors.NewBadRequest("cannot update a directory")
- }
-
- if err := r.gh.UpdateFile(ctx, r.owner, r.repo, finalPath, ref, comment, file.GetSHA(), data); err != nil {
- return fmt.Errorf("update file: %w", err)
- }
- return nil
-}
-
-func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
-
- ctx, _ = r.logger(ctx, ref)
- _, err := r.Read(ctx, path, ref)
- if err != nil && !(errors.Is(err, ErrFileNotFound)) {
- return fmt.Errorf("check if file exists before writing: %w", err)
- }
- if err == nil {
- return r.Update(ctx, path, ref, data, message)
- }
-
- return r.Create(ctx, path, ref, data, message)
-}
-
-func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
- ctx, _ = r.logger(ctx, ref)
-
- if err := r.ensureBranchExists(ctx, ref); err != nil {
- return err
- }
-
- // TODO: should add some protection against deleting the root directory?
-
- // Inside deleteRecursively, all paths are relative to the root of the repository
- // so we need to prepend the prefix there but only here.
- finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
-
- return r.deleteRecursively(ctx, finalPath, ref, comment)
-}
-
-func (r *githubRepository) deleteRecursively(ctx context.Context, path, ref, comment string) error {
- file, contents, err := r.gh.GetContents(ctx, r.owner, r.repo, path, ref)
- if err != nil {
- if errors.Is(err, pgh.ErrResourceNotFound) {
- return ErrFileNotFound
- }
-
- return fmt.Errorf("find file to delete: %w", err)
- }
-
- if file != nil && !file.IsDirectory() {
- return r.gh.DeleteFile(ctx, r.owner, r.repo, path, ref, comment, file.GetSHA())
- }
-
- for _, c := range contents {
- p := c.GetPath()
- if c.IsDirectory() {
- if err := r.deleteRecursively(ctx, p, ref, comment); err != nil {
- return fmt.Errorf("delete directory recursively: %w", err)
- }
- continue
- }
-
- if err := r.gh.DeleteFile(ctx, r.owner, r.repo, p, ref, comment, c.GetSHA()); err != nil {
- return fmt.Errorf("delete file: %w", err)
- }
- }
-
- return nil
-}
-
-func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
- ctx, _ = r.logger(ctx, ref)
-
- finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
- commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
- if err != nil {
- if errors.Is(err, pgh.ErrResourceNotFound) {
- return nil, ErrFileNotFound
- }
-
- return nil, fmt.Errorf("get commits: %w", err)
- }
-
- ret := make([]provisioning.HistoryItem, 0, len(commits))
- for _, commit := range commits {
- authors := make([]provisioning.Author, 0)
- if commit.Author != nil {
- authors = append(authors, provisioning.Author{
- Name: commit.Author.Name,
- Username: commit.Author.Username,
- AvatarURL: commit.Author.AvatarURL,
- })
- }
-
- if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
- authors = append(authors, provisioning.Author{
- Name: commit.Committer.Name,
- Username: commit.Committer.Username,
- AvatarURL: commit.Committer.AvatarURL,
- })
- }
-
- ret = append(ret, provisioning.HistoryItem{
- Ref: commit.Ref,
- Message: commit.Message,
- Authors: authors,
- CreatedAt: commit.CreatedAt.UnixMilli(),
- })
- }
-
- return ret, nil
-}
-
-// basicGitBranchNameRegex is a regular expression to validate a git branch name
-// it does not cover all cases as positive lookaheads are not supported in Go's regexp
-var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
-
-// IsValidGitBranchName checks if a branch name is valid.
-// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
-// 1. The branch name must have at least one character and must not be empty.
-// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
-// 3. The branch name cannot contain consecutive slashes (`//`).
-// 4. The branch name cannot contain consecutive dots (`..`).
-// 5. The branch name cannot contain `@{`.
-// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
-func IsValidGitBranchName(branch string) bool {
- if !basicGitBranchNameRegex.MatchString(branch) {
- return false
- }
-
- // Additional checks for invalid patterns
- if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
- strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
- strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
- return false
- }
-
- return true
-}
-
-func (r *githubRepository) ensureBranchExists(ctx context.Context, branchName string) error {
- if !IsValidGitBranchName(branchName) {
- return &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusBadRequest,
- Message: "invalid branch name",
- },
- }
- }
-
- ok, err := r.gh.BranchExists(ctx, r.owner, r.repo, branchName)
- if err != nil {
- return fmt.Errorf("check branch exists: %w", err)
- }
-
- if ok {
- logging.FromContext(ctx).Info("branch already exists", "branch", branchName)
-
- return nil
- }
-
- srcBranch := r.config.Spec.GitHub.Branch
- if err := r.gh.CreateBranch(ctx, r.owner, r.repo, srcBranch, branchName); err != nil {
- if errors.Is(err, pgh.ErrResourceAlreadyExists) {
- return &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusConflict,
- Message: "branch already exists",
- },
- }
- }
-
- return fmt.Errorf("create branch: %w", err)
- }
-
- return nil
-}
-
-func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
- ctx, _ = r.logger(ctx, "")
- branch, err := r.gh.GetBranch(ctx, r.owner, r.repo, r.Config().Spec.GitHub.Branch)
- if err != nil {
- return "", fmt.Errorf("get branch: %w", err)
- }
-
- return branch.Sha, nil
-}
-
-func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]VersionedFileChange, error) {
- if ref == "" {
- var err error
- ref, err = r.LatestRef(ctx)
- if err != nil {
- return nil, fmt.Errorf("get latest ref: %w", err)
- }
- }
- ctx, logger := r.logger(ctx, ref)
-
- files, err := r.gh.CompareCommits(ctx, r.owner, r.repo, base, ref)
- if err != nil {
- return nil, fmt.Errorf("compare commits: %w", err)
- }
-
- changes := make([]VersionedFileChange, 0)
- for _, f := range files {
- // reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
- switch f.GetStatus() {
- case "added", "copied":
- currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
- if err != nil {
- // do nothing as it's outside of configured path
- continue
- }
-
- changes = append(changes, VersionedFileChange{
- Path: currentPath,
- Ref: ref,
- Action: FileActionCreated,
- })
- case "modified", "changed":
- currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
- if err != nil {
- // do nothing as it's outside of configured path
- continue
- }
-
- changes = append(changes, VersionedFileChange{
- Path: currentPath,
- Ref: ref,
- Action: FileActionUpdated,
- })
- case "renamed":
- previousPath, previousErr := safepath.RelativeTo(f.GetPreviousFilename(), r.config.Spec.GitHub.Path)
- currentPath, currentErr := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
-
- // Handle all possible combinations of path validation results:
- // 1. Both paths outside configured path, do nothing
- // 2. Both paths inside configured path, rename
- // 3. Moving out of configured path, delete previous file
- // 4. Moving into configured path, create new file
- switch {
- case previousErr != nil && currentErr != nil:
- // do nothing as it's outside of configured path
- case previousErr == nil && currentErr == nil:
- changes = append(changes, VersionedFileChange{
- Path: currentPath,
- PreviousPath: previousPath,
- Ref: ref,
- PreviousRef: base,
- Action: FileActionRenamed,
- })
- case previousErr == nil && currentErr != nil:
- changes = append(changes, VersionedFileChange{
- Path: previousPath,
- Ref: base,
- Action: FileActionDeleted,
- })
- case previousErr != nil && currentErr == nil:
- changes = append(changes, VersionedFileChange{
- Path: currentPath,
- Ref: ref,
- Action: FileActionCreated,
- })
- }
- case "removed":
- currentPath, err := safepath.RelativeTo(f.GetFilename(), r.config.Spec.GitHub.Path)
- if err != nil {
- // do nothing as it's outside of configured path
- continue
- }
-
- changes = append(changes, VersionedFileChange{
- Ref: ref,
- PreviousRef: base,
- Path: currentPath,
- PreviousPath: currentPath,
- Action: FileActionDeleted,
- })
- case "unchanged":
- // do nothing
- default:
- logger.Error("ignore unhandled file", "file", f.GetFilename(), "status", f.GetStatus())
- }
- }
-
- return changes, nil
-}
-
-// ResourceURLs implements RepositoryWithURLs.
-func (r *githubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.ResourceURLs, error) {
- cfg := r.config.Spec.GitHub
- if file.Path == "" || cfg == nil {
- return nil, nil
- }
-
- ref := file.Ref
- if ref == "" {
- ref = cfg.Branch
- }
-
- urls := &provisioning.ResourceURLs{
- RepositoryURL: cfg.URL,
- SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
- }
-
- if ref != cfg.Branch {
- urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
-
- // Create a new pull request
- urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
- }
-
- return urls, nil
-}
-
-func (r *githubRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- return r.cloneFn(ctx, opts)
-}
-
-func (r *githubRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) {
- logger := logging.FromContext(ctx)
-
- type containsGh int
- var containsGhKey containsGh
- if ctx.Value(containsGhKey) != nil {
- return ctx, logging.FromContext(ctx)
- }
-
- if ref == "" {
- ref = r.config.Spec.GitHub.Branch
- }
- logger = logger.With(slog.Group("github_repository", "owner", r.owner, "name", r.repo, "ref", ref))
- ctx = logging.Context(ctx, logger)
- // We want to ensure we don't add multiple github_repository keys. With doesn't deduplicate the keys...
- ctx = context.WithValue(ctx, containsGhKey, true)
- return ctx, logger
-}
diff --git a/pkg/registry/apis/provisioning/repository/github/client.go b/pkg/registry/apis/provisioning/repository/github/client.go
index 9c35ca3898a..5b21a5a4f1e 100644
--- a/pkg/registry/apis/provisioning/repository/github/client.go
+++ b/pkg/registry/apis/provisioning/repository/github/client.go
@@ -7,127 +7,34 @@ import (
"errors"
"time"
- "github.com/google/go-github/v70/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
// API errors that we need to convey after parsing real GH errors (or faking them).
var (
- ErrResourceAlreadyExists = errors.New("the resource already exists")
- ErrResourceNotFound = errors.New("the resource does not exist")
- ErrMismatchedHash = errors.New("the update cannot be applied because the expected and actual hashes are unequal")
- ErrNoSecret = errors.New("new webhooks must have a secret")
+ ErrResourceNotFound = errors.New("the resource does not exist")
//lint:ignore ST1005 this is not punctuation
- ErrPathTraversalDisallowed = errors.New("the path contained ..") //nolint:staticcheck
- ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
- ErrFileTooLarge = errors.New("file exceeds maximum allowed size")
- ErrTooManyItems = errors.New("maximum number of items exceeded")
+ ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
+ ErrTooManyItems = errors.New("maximum number of items exceeded")
)
-// MaxFileSize maximum file size limit (10MB)
-const MaxFileSize = 10 * 1024 * 1024 // 10MB in bytes
-
-type ErrRateLimited = github.RateLimitError
-
//go:generate mockery --name Client --structname MockClient --inpackage --filename mock_client.go --with-expecter
type Client interface {
- // IsAuthenticated checks if the client is authenticated.
- IsAuthenticated(ctx context.Context) error
-
- // GetContents returns the metadata and content of a file or directory.
- // When a file is checked, the first returned value will have a value. For a directory, the second will. The other value is always nil.
- // If an error occurs, the returned values may or may not be nil.
- //
- // If ".." appears in the "path", this method will return an error.
- GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error)
-
- // GetTree returns the Git tree in the repository.
- // When recursive is given, subtrees are mapped into the returned array.
- // When basePath is given, only trees under it are given. The results do not include this path in their names.
- //
- // The truncated bool will be set to true if the tree is larger than 7 MB or 100 000 entries.
- // When truncated is true, you may wish to read each subtree manually instead.
- GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) (entries []RepositoryContent, truncated bool, err error)
-
- // CreateFile creates a new file in the repository under the given path.
- // The file is created on the branch given.
- // The message given is the commit message. If none is given, an appropriate default is used.
- // The content is what the file should contain. An empty slice is valid, though often not very useful.
- //
- // If ".." appears in the "path", this method will return an error.
- CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error
-
- // UpdateFile updates a file in the repository under the given path.
- // The file is updated on the branch given.
- // The message given is the commit message. If none is given, an appropriate default is used.
- // The content is what the file should contain. An empty slice is valid, though often not very useful.
- // If the path does not exist, an error is returned.
- // The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
- //
- // If ".." appears in the "path", this method will return an error.
- UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error
-
- // DeleteFile deletes a file in the repository under the given path.
- // The file is deleted from the branch given.
- // The message given is the commit message. If none is given, an appropriate default is used.
- // If the path does not exist, an error is returned.
- // The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
- //
- // If ".." appears in the "path", this method will return an error.
- DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error
-
- // Commits returns the commits for the given path
+ // Commits
Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error)
- // CompareCommits returns the changes between two commits.
- CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error)
-
- // RepoExists checks if a repository exists.
- RepoExists(ctx context.Context, owner, repository string) (bool, error)
-
- // CreateBranch creates a new branch in the repository.
- CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error
- // BranchExists checks if a branch exists in the repository.
- BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error)
- // GetBranch returns the branch of the repository.
- GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error)
-
+ // Webhooks
ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error)
CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error)
GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error)
DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error
EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error
+ // Pull requests
ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error)
CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error
}
-//go:generate mockery --name RepositoryContent --structname MockRepositoryContent --inpackage --filename mock_repository_content.go --with-expecter
-type RepositoryContent interface {
- // Returns true if this is a directory, false if it is a file.
- IsDirectory() bool
- // Returns the contents of the file. Decoding happens if necessary.
- // Returns an error if the content represents a directory.
- GetFileContent() (string, error)
- // Returns true if this is a symlink.
- // If true, GetPath returns the path where this symlink leads.
- IsSymlink() bool
- // Returns the full path from the root of the repository.
- // This has no leading or trailing slashes.
- // The path only uses '/' for directories. You can use the 'path' package to interact with these.
- GetPath() string
- // Get the SHA hash. This is usually a SHA-256, but may also be SHA-512.
- // Directories have SHA hashes, too (TODO: how is this calculated?).
- GetSHA() string
- // The size of the file. Not necessarily non-zero, even if the file is supposed to be non-zero.
- GetSize() int64
-}
-
-type Branch struct {
- Name string
- Sha string
-}
-
type CommitAuthor struct {
Name string
Username string
@@ -150,20 +57,6 @@ type CommitFile interface {
GetStatus() string
}
-type FileComment struct {
- Content string
- Path string
- Position int
- Ref string
-}
-
-type CreateFileOptions struct {
- // The message of the commit. May be empty, in which case a default value is entered.
- Message string
- // The content of the file to write, unencoded.
- Content []byte
-}
-
type WebhookConfig struct {
// The ID of the webhook.
// Can be 0 on creation.
diff --git a/pkg/registry/apis/provisioning/repository/github/factory.go b/pkg/registry/apis/provisioning/repository/github/factory.go
index b143cd7ad74..da2d50a6131 100644
--- a/pkg/registry/apis/provisioning/repository/github/factory.go
+++ b/pkg/registry/apis/provisioning/repository/github/factory.go
@@ -27,6 +27,11 @@ func (r *Factory) New(ctx context.Context, ghToken string) Client {
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghToken},
)
- tokenClient := oauth2.NewClient(ctx, tokenSrc)
- return NewClient(github.NewClient(tokenClient))
+
+ if len(ghToken) == 0 {
+ tokenClient := oauth2.NewClient(ctx, tokenSrc)
+ return NewClient(github.NewClient(tokenClient))
+ }
+
+ return NewClient(github.NewClient(&http.Client{}))
}
diff --git a/pkg/registry/apis/provisioning/repository/github_repository_mock.go b/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go
similarity index 85%
rename from pkg/registry/apis/provisioning/repository/github_repository_mock.go
rename to pkg/registry/apis/provisioning/repository/github/github_repository_mock.go
index 0ae6a3f98f5..c40a23d709d 100644
--- a/pkg/registry/apis/provisioning/repository/github_repository_mock.go
+++ b/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go
@@ -1,14 +1,14 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
-package repository
+package github
import (
context "context"
- github "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
+ mock "github.com/stretchr/testify/mock"
field "k8s.io/apimachinery/pkg/util/validation/field"
- mock "github.com/stretchr/testify/mock"
+ repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
@@ -27,19 +27,19 @@ func (_m *MockGithubRepository) EXPECT() *MockGithubRepository_Expecter {
}
// Client provides a mock function with no fields
-func (_m *MockGithubRepository) Client() github.Client {
+func (_m *MockGithubRepository) Client() Client {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Client")
}
- var r0 github.Client
- if rf, ok := ret.Get(0).(func() github.Client); ok {
+ var r0 Client
+ if rf, ok := ret.Get(0).(func() Client); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).(github.Client)
+ r0 = ret.Get(0).(Client)
}
}
@@ -63,93 +63,34 @@ func (_c *MockGithubRepository_Client_Call) Run(run func()) *MockGithubRepositor
return _c
}
-func (_c *MockGithubRepository_Client_Call) Return(_a0 github.Client) *MockGithubRepository_Client_Call {
+func (_c *MockGithubRepository_Client_Call) Return(_a0 Client) *MockGithubRepository_Client_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockGithubRepository_Client_Call) RunAndReturn(run func() github.Client) *MockGithubRepository_Client_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// Clone provides a mock function with given fields: ctx, opts
-func (_m *MockGithubRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- ret := _m.Called(ctx, opts)
-
- if len(ret) == 0 {
- panic("no return value specified for Clone")
- }
-
- var r0 ClonedRepository
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok {
- return rf(ctx, opts)
- }
- if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok {
- r0 = rf(ctx, opts)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(ClonedRepository)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok {
- r1 = rf(ctx, opts)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockGithubRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone'
-type MockGithubRepository_Clone_Call struct {
- *mock.Call
-}
-
-// Clone is a helper method to define mock.On call
-// - ctx context.Context
-// - opts CloneOptions
-func (_e *MockGithubRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGithubRepository_Clone_Call {
- return &MockGithubRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)}
-}
-
-func (_c *MockGithubRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGithubRepository_Clone_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(CloneOptions))
- })
- return _c
-}
-
-func (_c *MockGithubRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGithubRepository_Clone_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockGithubRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGithubRepository_Clone_Call {
+func (_c *MockGithubRepository_Client_Call) RunAndReturn(run func() Client) *MockGithubRepository_Client_Call {
_c.Call.Return(run)
return _c
}
// CompareFiles provides a mock function with given fields: ctx, base, ref
-func (_m *MockGithubRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) {
+func (_m *MockGithubRepository) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) {
ret := _m.Called(ctx, base, ref)
if len(ret) == 0 {
panic("no return value specified for CompareFiles")
}
- var r0 []VersionedFileChange
+ var r0 []repository.VersionedFileChange
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok {
return rf(ctx, base, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok {
r0 = rf(ctx, base, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).([]VersionedFileChange)
+ r0 = ret.Get(0).([]repository.VersionedFileChange)
}
}
@@ -182,12 +123,12 @@ func (_c *MockGithubRepository_CompareFiles_Call) Run(run func(ctx context.Conte
return _c
}
-func (_c *MockGithubRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGithubRepository_CompareFiles_Call {
+func (_c *MockGithubRepository_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockGithubRepository_CompareFiles_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGithubRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGithubRepository_CompareFiles_Call {
+func (_c *MockGithubRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockGithubRepository_CompareFiles_Call {
_c.Call.Return(run)
return _c
}
@@ -500,23 +441,23 @@ func (_c *MockGithubRepository_Owner_Call) RunAndReturn(run func() string) *Mock
}
// Read provides a mock function with given fields: ctx, path, ref
-func (_m *MockGithubRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
+func (_m *MockGithubRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
panic("no return value specified for Read")
}
- var r0 *FileInfo
+ var r0 *repository.FileInfo
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok {
return rf(ctx, path, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok {
r0 = rf(ctx, path, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).(*FileInfo)
+ r0 = ret.Get(0).(*repository.FileInfo)
}
}
@@ -549,34 +490,34 @@ func (_c *MockGithubRepository_Read_Call) Run(run func(ctx context.Context, path
return _c
}
-func (_c *MockGithubRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGithubRepository_Read_Call {
+func (_c *MockGithubRepository_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockGithubRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGithubRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGithubRepository_Read_Call {
+func (_c *MockGithubRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockGithubRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
-func (_m *MockGithubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+func (_m *MockGithubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
panic("no return value specified for ReadTree")
}
- var r0 []FileTreeEntry
+ var r0 []repository.FileTreeEntry
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string) ([]repository.FileTreeEntry, error)); ok {
return rf(ctx, ref)
}
- if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, string) []repository.FileTreeEntry); ok {
r0 = rf(ctx, ref)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).([]FileTreeEntry)
+ r0 = ret.Get(0).([]repository.FileTreeEntry)
}
}
@@ -608,12 +549,12 @@ func (_c *MockGithubRepository_ReadTree_Call) Run(run func(ctx context.Context,
return _c
}
-func (_c *MockGithubRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGithubRepository_ReadTree_Call {
+func (_c *MockGithubRepository_ReadTree_Call) Return(_a0 []repository.FileTreeEntry, _a1 error) *MockGithubRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGithubRepository_ReadTree_Call {
+func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]repository.FileTreeEntry, error)) *MockGithubRepository_ReadTree_Call {
_c.Call.Return(run)
return _c
}
@@ -664,7 +605,7 @@ func (_c *MockGithubRepository_Repo_Call) RunAndReturn(run func() string) *MockG
}
// ResourceURLs provides a mock function with given fields: ctx, file
-func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*v0alpha1.ResourceURLs, error) {
+func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*v0alpha1.ResourceURLs, error) {
ret := _m.Called(ctx, file)
if len(ret) == 0 {
@@ -673,10 +614,10 @@ func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo
var r0 *v0alpha1.ResourceURLs
var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)); ok {
return rf(ctx, file)
}
- if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) *v0alpha1.ResourceURLs); ok {
+ if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) *v0alpha1.ResourceURLs); ok {
r0 = rf(ctx, file)
} else {
if ret.Get(0) != nil {
@@ -684,7 +625,7 @@ func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo
}
}
- if rf, ok := ret.Get(1).(func(context.Context, *FileInfo) error); ok {
+ if rf, ok := ret.Get(1).(func(context.Context, *repository.FileInfo) error); ok {
r1 = rf(ctx, file)
} else {
r1 = ret.Error(1)
@@ -700,14 +641,14 @@ type MockGithubRepository_ResourceURLs_Call struct {
// ResourceURLs is a helper method to define mock.On call
// - ctx context.Context
-// - file *FileInfo
+// - file *repository.FileInfo
func (_e *MockGithubRepository_Expecter) ResourceURLs(ctx interface{}, file interface{}) *MockGithubRepository_ResourceURLs_Call {
return &MockGithubRepository_ResourceURLs_Call{Call: _e.mock.On("ResourceURLs", ctx, file)}
}
-func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Context, file *FileInfo)) *MockGithubRepository_ResourceURLs_Call {
+func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Context, file *repository.FileInfo)) *MockGithubRepository_ResourceURLs_Call {
_c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(*FileInfo))
+ run(args[0].(context.Context), args[1].(*repository.FileInfo))
})
return _c
}
@@ -717,7 +658,66 @@ func (_c *MockGithubRepository_ResourceURLs_Call) Return(_a0 *v0alpha1.ResourceU
return _c
}
-func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call {
+func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Stage provides a mock function with given fields: ctx, opts
+func (_m *MockGithubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
+ ret := _m.Called(ctx, opts)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Stage")
+ }
+
+ var r0 repository.StagedRepository
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) (repository.StagedRepository, error)); ok {
+ return rf(ctx, opts)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, repository.StageOptions) repository.StagedRepository); ok {
+ r0 = rf(ctx, opts)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(repository.StagedRepository)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, repository.StageOptions) error); ok {
+ r1 = rf(ctx, opts)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockGithubRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
+type MockGithubRepository_Stage_Call struct {
+ *mock.Call
+}
+
+// Stage is a helper method to define mock.On call
+// - ctx context.Context
+// - opts repository.StageOptions
+func (_e *MockGithubRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockGithubRepository_Stage_Call {
+ return &MockGithubRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
+}
+
+func (_c *MockGithubRepository_Stage_Call) Run(run func(ctx context.Context, opts repository.StageOptions)) *MockGithubRepository_Stage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(repository.StageOptions))
+ })
+ return _c
+}
+
+func (_c *MockGithubRepository_Stage_Call) Return(_a0 repository.StagedRepository, _a1 error) *MockGithubRepository_Stage_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockGithubRepository_Stage_Call) RunAndReturn(run func(context.Context, repository.StageOptions) (repository.StagedRepository, error)) *MockGithubRepository_Stage_Call {
_c.Call.Return(run)
return _c
}
diff --git a/pkg/registry/apis/provisioning/repository/github/impl.go b/pkg/registry/apis/provisioning/repository/github/impl.go
index d94f2a544cb..c9f5468bc8d 100644
--- a/pkg/registry/apis/provisioning/repository/github/impl.go
+++ b/pkg/registry/apis/provisioning/repository/github/impl.go
@@ -8,10 +8,6 @@ import (
"time"
"github.com/google/go-github/v70/github"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
type githubClient struct {
@@ -22,268 +18,12 @@ func NewClient(client *github.Client) Client {
return &githubClient{client}
}
-func (r *githubClient) IsAuthenticated(ctx context.Context) error {
- if _, _, err := r.gh.Users.Get(ctx, ""); err != nil {
- var ghErr *github.ErrorResponse
- if errors.As(err, &ghErr) {
- switch ghErr.Response.StatusCode {
- case http.StatusUnauthorized:
- return apierrors.NewUnauthorized("token is invalid or expired")
- case http.StatusForbidden:
- return &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Status: metav1.StatusFailure,
- Code: http.StatusUnauthorized,
- Reason: metav1.StatusReasonUnauthorized,
- Message: "token is revoked or has insufficient permissions",
- },
- }
- case http.StatusServiceUnavailable:
- return ErrServiceUnavailable
- }
- }
-
- return err
- }
-
- return nil
-}
-
-func (r *githubClient) RepoExists(ctx context.Context, owner, repository string) (bool, error) {
- _, resp, err := r.gh.Repositories.Get(ctx, owner, repository)
- if err == nil {
- return true, nil
- }
- if resp.StatusCode == http.StatusNotFound {
- return false, nil
- }
-
- return false, err
-}
-
const (
- maxDirectoryItems = 1000 // Maximum number of items allowed in a directory
- maxTreeItems = 10000 // Maximum number of items allowed in a tree
- maxCommits = 1000 // Maximum number of commits to fetch
- maxCompareFiles = 1000 // Maximum number of files to compare between commits
- maxWebhooks = 100 // Maximum number of webhooks allowed per repository
- maxPRFiles = 1000 // Maximum number of files allowed in a pull request
- maxPullRequestsFileComments = 1000 // Maximum number of comments allowed in a pull request
- maxFileSize = 10 * 1024 * 1024 // 10MB in bytes
+ maxCommits = 1000 // Maximum number of commits to fetch
+ maxWebhooks = 100 // Maximum number of webhooks allowed per repository
+ maxPRFiles = 1000 // Maximum number of files allowed in a pull request
)
-func (r *githubClient) GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error) {
- // First try to get repository contents
- opts := &github.RepositoryContentGetOptions{
- Ref: ref,
- }
-
- fc, dc, _, err := r.gh.Repositories.GetContents(ctx, owner, repository, path, opts)
- if err != nil {
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return nil, nil, err
- }
- if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
- return nil, nil, ErrServiceUnavailable
- }
- if ghErr.Response.StatusCode == http.StatusNotFound {
- return nil, nil, ErrResourceNotFound
- }
- return nil, nil, err
- }
-
- if fc != nil {
- // Check file size before returning content
- if fc.GetSize() > maxFileSize {
- return nil, nil, ErrFileTooLarge
- }
- return realRepositoryContent{fc}, nil, nil
- }
-
- // For directories, check size limits
- if len(dc) > maxDirectoryItems {
- return nil, nil, fmt.Errorf("directory contains too many items (more than %d)", maxDirectoryItems)
- }
-
- // Convert directory contents
- allContents := make([]RepositoryContent, 0, len(dc))
- for _, original := range dc {
- allContents = append(allContents, realRepositoryContent{original})
- }
-
- return nil, allContents, nil
-}
-
-func (r *githubClient) GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) ([]RepositoryContent, bool, error) {
- var tree *github.Tree
- var err error
-
- subPaths := safepath.Split(basePath)
- currentRef := ref
-
- for {
- // If subPaths is empty, we can read recursively, as we're reading the tree from the "base" of the repository. Otherwise, always read only the direct children.
- recursive := recursive && len(subPaths) == 0
-
- tree, _, err = r.gh.Git.GetTree(ctx, owner, repository, currentRef, recursive)
- if err != nil {
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return nil, false, err
- }
- if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
- return nil, false, ErrServiceUnavailable
- }
- if ghErr.Response.StatusCode == http.StatusNotFound {
- if currentRef != ref {
- // We're operating with a subpath which doesn't exist yet.
- // Pretend as if there is simply no files.
- // FIXME: why should we pretend this?
- return nil, false, nil
- }
- // currentRef == ref
- // This indicates the repository or commitish reference doesn't exist. This should always return an error.
- return nil, false, ErrResourceNotFound
- }
- return nil, false, err
- }
-
- // Check if we've exceeded the maximum allowed items
- if len(tree.Entries) > maxTreeItems {
- return nil, false, fmt.Errorf("tree contains too many items (more than %d)", maxTreeItems)
- }
-
- // Prep for next iteration.
- if len(subPaths) == 0 {
- // We're done: we've discovered the tree we want.
- break
- }
-
- // the ref must be equal the SHA of the entry corresponding to subPaths[0]
- currentRef = ""
- for _, e := range tree.Entries {
- if e.GetPath() == subPaths[0] {
- currentRef = e.GetSHA()
- break
- }
- }
- subPaths = subPaths[1:]
- if currentRef == "" {
- // We couldn't find the folder in the tree...
- return nil, false, nil
- }
- }
-
- // If the tree is truncated and we're in recursive mode, return an error
- if tree.GetTruncated() && recursive {
- return nil, true, fmt.Errorf("tree is too large to fetch recursively (more than %d items)", maxTreeItems)
- }
-
- entries := make([]RepositoryContent, 0, len(tree.Entries))
- for _, te := range tree.Entries {
- rrc := &realRepositoryContent{
- real: &github.RepositoryContent{
- Path: te.Path,
- Size: te.Size,
- SHA: te.SHA,
- },
- }
- if te.GetType() == "tree" {
- rrc.real.Type = github.Ptr("dir")
- } else {
- rrc.real.Type = te.Type
- }
- entries = append(entries, rrc)
- }
- return entries, tree.GetTruncated(), nil
-}
-
-func (r *githubClient) CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error {
- if message == "" {
- message = fmt.Sprintf("Create %s", path)
- }
-
- _, _, err := r.gh.Repositories.CreateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
- Branch: &branch,
- Message: &message,
- Content: content,
- })
- if err == nil {
- return nil
- }
-
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return err
- }
- if ghErr.Response.StatusCode == http.StatusUnprocessableEntity {
- return ErrResourceAlreadyExists
- }
- return err
-}
-
-func (r *githubClient) UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error {
- if message == "" {
- message = fmt.Sprintf("Update %s", path)
- }
-
- _, _, err := r.gh.Repositories.UpdateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
- Branch: &branch,
- Message: &message,
- Content: content,
- SHA: &hash,
- })
- if err == nil {
- return nil
- }
-
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return err
- }
- if ghErr.Response.StatusCode == http.StatusNotFound {
- return ErrResourceNotFound
- }
- if ghErr.Response.StatusCode == http.StatusConflict {
- return ErrMismatchedHash
- }
- if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
- return ErrServiceUnavailable
- }
- return err
-}
-
-func (r *githubClient) DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error {
- if message == "" {
- message = fmt.Sprintf("Delete %s", path)
- }
-
- _, _, err := r.gh.Repositories.DeleteFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
- Branch: &branch,
- Message: &message,
- SHA: &hash,
- })
- if err == nil {
- return nil
- }
-
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return err
- }
- if ghErr.Response.StatusCode == http.StatusNotFound {
- return ErrResourceNotFound
- }
- if ghErr.Response.StatusCode == http.StatusConflict {
- return ErrMismatchedHash
- }
- if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
- return ErrServiceUnavailable
- }
- return err
-}
-
// Commits returns a list of commits for a given repository and branch.
func (r *githubClient) Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
@@ -343,105 +83,6 @@ func (r *githubClient) Commits(ctx context.Context, owner, repository, path, bra
return ret, nil
}
-func (r *githubClient) CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error) {
- listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.CommitFile, *github.Response, error) {
- compare, resp, err := r.gh.Repositories.CompareCommits(ctx, owner, repository, base, head, opts)
- if err != nil {
- return nil, resp, err
- }
- return compare.Files, resp, nil
- }
-
- files, err := paginatedList(
- ctx,
- listFn,
- defaultListOptions(maxCompareFiles),
- )
- if errors.Is(err, ErrTooManyItems) {
- return nil, fmt.Errorf("too many files changed between commits (more than %d)", maxCompareFiles)
- }
- if err != nil {
- return nil, err
- }
-
- // Convert to the interface type
- ret := make([]CommitFile, 0, len(files))
- for _, f := range files {
- ret = append(ret, f)
- }
-
- return ret, nil
-}
-
-func (r *githubClient) GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error) {
- branch, resp, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
- if err != nil {
- // For some reason, GitHub client handles this case differently by failing with a wrapped error
- if resp != nil && resp.StatusCode == http.StatusNotFound {
- return Branch{}, ErrResourceNotFound
- }
-
- if resp != nil && resp.StatusCode == http.StatusServiceUnavailable {
- return Branch{}, ErrServiceUnavailable
- }
-
- var ghErr *github.ErrorResponse
- if !errors.As(err, &ghErr) {
- return Branch{}, err
- }
- // Leaving these just in case
- if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
- return Branch{}, ErrServiceUnavailable
- }
- if ghErr.Response.StatusCode == http.StatusNotFound {
- return Branch{}, ErrResourceNotFound
- }
- return Branch{}, err
- }
-
- return Branch{
- Name: branch.GetName(),
- Sha: branch.GetCommit().GetSHA(),
- }, nil
-}
-
-func (r *githubClient) CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error {
- // Fail if the branch already exists
- if _, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0); err == nil {
- return ErrResourceAlreadyExists
- }
-
- // Branch out based on the repository branch
- baseRef, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, sourceBranch, 0)
- if err != nil {
- return fmt.Errorf("get base branch: %w", err)
- }
-
- if _, _, err := r.gh.Git.CreateRef(ctx, owner, repository, &github.Reference{
- Ref: github.Ptr(fmt.Sprintf("refs/heads/%s", branchName)),
- Object: &github.GitObject{
- SHA: baseRef.Commit.SHA,
- },
- }); err != nil {
- return fmt.Errorf("create branch ref: %w", err)
- }
-
- return nil
-}
-
-func (r *githubClient) BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error) {
- _, resp, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
- if err == nil {
- return true, nil
- }
-
- if resp.StatusCode == http.StatusNotFound {
- return false, nil
- }
-
- return false, err
-}
-
func (r *githubClient) ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) {
return r.gh.Repositories.ListHooks(ctx, owner, repository, opts)
@@ -626,44 +267,6 @@ func (r *githubClient) CreatePullRequestComment(ctx context.Context, owner, repo
return nil
}
-type realRepositoryContent struct {
- real *github.RepositoryContent
-}
-
-var _ RepositoryContent = realRepositoryContent{}
-
-func (c realRepositoryContent) IsDirectory() bool {
- return c.real.GetType() == "dir"
-}
-
-func (c realRepositoryContent) GetFileContent() (string, error) {
- return c.real.GetContent()
-}
-
-func (c realRepositoryContent) IsSymlink() bool {
- return c.real.Target != nil
-}
-
-func (c realRepositoryContent) GetPath() string {
- return c.real.GetPath()
-}
-
-func (c realRepositoryContent) GetSHA() string {
- return c.real.GetSHA()
-}
-
-func (c realRepositoryContent) GetSize() int64 {
- if c.real.Size != nil {
- return int64(*c.real.Size)
- }
- if c.real.Content != nil {
- if c, err := c.real.GetContent(); err == nil {
- return int64(len(c))
- }
- }
- return 0
-}
-
// listOptions represents pagination parameters for list operations
type listOptions struct {
github.ListOptions
diff --git a/pkg/registry/apis/provisioning/repository/github/impl_test.go b/pkg/registry/apis/provisioning/repository/github/impl_test.go
index b979f288836..fedbc2a9850 100644
--- a/pkg/registry/apis/provisioning/repository/github/impl_test.go
+++ b/pkg/registry/apis/provisioning/repository/github/impl_test.go
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
- "strings"
"testing"
"time"
@@ -15,1415 +14,8 @@ import (
mockhub "github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
)
-func TestIsAuthenticated(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- wantErr error
- }{
- {
- name: "successful authentication",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatch(
- mockhub.GetUser,
- github.User{},
- ),
- ),
- wantErr: nil,
- },
- {
- name: "unauthorized - invalid token",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetUser,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Bad credentials"}))
- }),
- ),
- ),
- wantErr: apierrors.NewUnauthorized("token is invalid or expired"),
- },
- {
- name: "forbidden - insufficient permissions",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetUser,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusForbidden)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Forbidden"}))
- }),
- ),
- ),
- wantErr: apierrors.NewUnauthorized("token is revoked or has insufficient permissions"),
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetUser,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Service unavailable"}))
- }),
- ),
- ),
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "unknown error",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetUser,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Internal server error"}))
- }),
- ),
- ),
- wantErr: errors.New("500 Internal server error []"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- err := client.IsAuthenticated(context.Background())
- // Check the error
- if tt.wantErr == nil {
- assert.NoError(t, err)
- } else {
- assert.Error(t, err)
- var statusErr *apierrors.StatusError
- if errors.As(tt.wantErr, &statusErr) {
- // For StatusError, compare status code
- var actualStatusErr *apierrors.StatusError
- assert.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
- if actualStatusErr != nil {
- assert.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
- assert.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
- }
- } else {
- // For regular errors, compare error messages
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- }
- })
- }
-}
-func TestGithubClient_RepoExists(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- want bool
- wantErr error
- }{
- {
- name: "repository exists",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{
- "id": 123,
- "name": "test-repo",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- want: true,
- wantErr: nil,
- },
- {
- name: "repository does not exist",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Not Found"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "non-existent-repo",
- want: false,
- wantErr: nil,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Service unavailable"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- want: false,
- wantErr: errors.New("503 Service unavailable []"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- exists, err := client.RepoExists(context.Background(), tt.owner, tt.repository)
-
- // Check the result
- assert.Equal(t, tt.want, exists)
-
- // Check the error
- if tt.wantErr == nil {
- assert.NoError(t, err)
- } else {
- assert.Error(t, err)
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- })
- }
-}
-
-func TestGithubClient_GetContents(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- path string
- ref string
- wantFile bool
- wantDir bool
- wantErr error
- }{
- {
- name: "get file contents",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- fileContent := &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr("test.txt"),
- Path: github.Ptr("test.txt"),
- Content: github.Ptr("dGVzdCBjb250ZW50"), // base64 encoded "test content"
- Encoding: github.Ptr("base64"),
- Size: github.Ptr(12),
- SHA: github.Ptr("abc123"),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(fileContent))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- ref: "main",
- wantFile: true,
- wantDir: false,
- wantErr: nil,
- },
- {
- name: "get directory contents",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- dirContents := []*github.RepositoryContent{
- {
- Type: github.Ptr("file"),
- Name: github.Ptr("file1.txt"),
- Path: github.Ptr("dir/file1.txt"),
- Size: github.Ptr(100),
- SHA: github.Ptr("abc123"),
- },
- {
- Type: github.Ptr("dir"),
- Name: github.Ptr("subdir"),
- Path: github.Ptr("dir/subdir"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(dirContents))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "dir",
- ref: "main",
- wantFile: false,
- wantDir: true,
- wantErr: nil,
- },
- {
- name: "resource not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Not Found"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "nonexistent.txt",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: ErrResourceNotFound,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Service unavailable"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "file too large",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- fileContent := &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr("large.txt"),
- Path: github.Ptr("large.txt"),
- Content: github.Ptr(""),
- Encoding: github.Ptr("base64"),
- Size: github.Ptr(maxFileSize + 1), // Exceeds max file size
- SHA: github.Ptr("abc123"),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(fileContent))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "large.txt",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: ErrFileTooLarge,
- },
- {
- name: "not a github error response",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusConflict)
- // Return a non-GitHub error format
- _, err := w.Write([]byte("not a github error"))
- require.NoError(t, err)
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: errors.New("409"),
- },
- {
- name: "directory with too many items",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- // Create a directory with more than maxDirectoryItems
- dirContents := make([]*github.RepositoryContent, maxDirectoryItems+1)
- for i := 0; i < maxDirectoryItems+1; i++ {
- dirContents[i] = &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr(fmt.Sprintf("file%d.txt", i)),
- Path: github.Ptr(fmt.Sprintf("dir/file%d.txt", i)),
- Size: github.Ptr(100),
- SHA: github.Ptr(fmt.Sprintf("sha%d", i)),
- }
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(dirContents))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "dir",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: fmt.Errorf("directory contains too many items (more than %d)", maxDirectoryItems),
- },
- {
- name: "error response with other status code",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusForbidden)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusForbidden,
- },
- Message: "Forbidden access",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- ref: "main",
- wantFile: false,
- wantDir: false,
- wantErr: errors.New("Forbidden access"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- fileContent, dirContents, err := client.GetContents(context.Background(), tt.owner, tt.repository, tt.path, tt.ref)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- assert.Nil(t, fileContent)
- assert.Nil(t, dirContents)
- return
- }
- assert.NoError(t, err)
-
- // Check the result
- if tt.wantFile {
- assert.NotNil(t, fileContent)
- assert.Nil(t, dirContents)
- } else if tt.wantDir {
- assert.Nil(t, fileContent)
- assert.NotNil(t, dirContents)
- assert.Greater(t, len(dirContents), 0)
- }
- })
- }
-}
-
-func TestGithubClient_GetTree(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- basePath string
- ref string
- recursive bool
- wantItems int
- wantTrunc bool
- wantErr error
- }{
- {
- name: "get tree successfully",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- tree := &github.Tree{
- SHA: github.Ptr("abc123"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("file1.txt"),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(12),
- SHA: github.Ptr("file1sha"),
- },
- {
- Path: github.Ptr("file2.txt"),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(14),
- SHA: github.Ptr("file2sha"),
- },
- {
- Path: github.Ptr("dir"),
- Mode: github.Ptr("040000"),
- Type: github.Ptr("tree"),
- SHA: github.Ptr("dirsha"),
- },
- },
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 3,
- wantTrunc: false,
- wantErr: nil,
- },
- {
- name: "get tree with subpath",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Check if this is the first request for the root tree
- if !strings.Contains(r.URL.Path, "subdirsha") {
- // Verify the request URL contains the correct owner, repo, and ref
- expectedPath := "/repos/test-owner/test-repo/git/trees/main"
- assert.True(t, strings.Contains(r.URL.Path, expectedPath),
- "Expected URL path to contain %s, got %s", expectedPath, r.URL.Path)
-
- // Verify query parameters for recursive flag
- query := r.URL.Query()
- assert.Equal(t, "", query.Get("recursive"), "Recursive parameter should not be set")
- } else {
- // This is the second request for the subtree
- assert.True(t, strings.Contains(r.URL.Path, "subdirsha"),
- "Expected URL path to contain subdirsha, got %s", r.URL.Path)
- }
- // First request for the root tree
- tree := &github.Tree{
- SHA: github.Ptr("rootsha"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("subdir"),
- Mode: github.Ptr("040000"),
- Type: github.Ptr("tree"),
- SHA: github.Ptr("subdirsha"),
- },
- },
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Second request for the subdir tree
- if strings.Contains(r.URL.Path, "subdirsha") {
- tree := &github.Tree{
- SHA: github.Ptr("subdirsha"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("file3.txt"),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(16),
- SHA: github.Ptr("file3sha"),
- },
- },
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "subdir",
- ref: "main",
- recursive: false,
- wantItems: 1,
- wantTrunc: false,
- wantErr: nil,
- },
- {
- name: "subpath not found should pretend is empty",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // First request for the root tree
- if !strings.Contains(r.URL.Path, "nonexistentsha") {
- tree := &github.Tree{
- SHA: github.Ptr("rootsha"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("nonexistent"),
- Mode: github.Ptr("040000"),
- Type: github.Ptr("tree"),
- SHA: github.Ptr("nonexistentsha"),
- },
- },
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- } else {
- // Second request for the nonexistent subtree returns 404
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Not Found",
- }))
- }
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "nonexistent",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: nil,
- },
- {
- name: "get tree fails with service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "tree contains too many items",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- // Create more entries than the maxTreeItems limit
- entries := make([]*github.TreeEntry, maxTreeItems+1)
- for i := 0; i < maxTreeItems+1; i++ {
- entries[i] = &github.TreeEntry{
- Path: github.Ptr(fmt.Sprintf("file%d.txt", i+1)),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(12),
- SHA: github.Ptr(fmt.Sprintf("sha%d", i+1)),
- }
- }
-
- tree := &github.Tree{
- SHA: github.Ptr("abc123"),
- Entries: entries,
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: fmt.Errorf("tree contains too many items (more than %d)", maxTreeItems),
- },
-
- {
- name: "tree is truncated with recursive mode",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- tree := &github.Tree{
- SHA: github.Ptr("abc123"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("file1.txt"),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(12),
- SHA: github.Ptr("file1sha"),
- },
- },
- Truncated: github.Ptr(true),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: true,
- wantItems: 0,
- wantTrunc: true,
- wantErr: fmt.Errorf("tree is too large to fetch recursively (more than 10000 items)"),
- },
- {
- name: "repository not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Not Found",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "non-existent-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: ErrResourceNotFound,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "too many items in tree",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- // Create a tree with more than maxTreeItems entries
- entries := make([]*github.TreeEntry, maxTreeItems+1)
- for i := 0; i < maxTreeItems+1; i++ {
- entries[i] = &github.TreeEntry{
- Path: github.Ptr(fmt.Sprintf("file%d.txt", i)),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(12),
- SHA: github.Ptr(fmt.Sprintf("sha%d", i)),
- }
- }
- tree := &github.Tree{
- SHA: github.Ptr("abc123"),
- Entries: entries,
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: fmt.Errorf("tree contains too many items (more than 10000)"),
- },
- {
- name: "folder not found in tree",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- // Return a tree that doesn't contain the requested folder
- tree := &github.Tree{
- SHA: github.Ptr("rootsha"),
- Entries: []*github.TreeEntry{
- {
- Path: github.Ptr("other-folder"),
- Mode: github.Ptr("040000"),
- Type: github.Ptr("tree"),
- SHA: github.Ptr("othersha"),
- },
- {
- Path: github.Ptr("file.txt"),
- Mode: github.Ptr("100644"),
- Type: github.Ptr("blob"),
- Size: github.Ptr(12),
- SHA: github.Ptr("filesha"),
- },
- },
- Truncated: github.Ptr(false),
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(tree))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "non-existent-folder/subpath",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: nil,
- },
- {
- name: "non-standard error is passed through",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposGitTreesByOwnerByRepoByTreeSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusForbidden)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusForbidden,
- },
- Message: "Forbidden access",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- basePath: "",
- ref: "main",
- recursive: false,
- wantItems: 0,
- wantTrunc: false,
- wantErr: errors.New("Forbidden access"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- contents, truncated, err := client.GetTree(context.Background(), tt.owner, tt.repository, tt.basePath, tt.ref, tt.recursive)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- assert.Nil(t, contents)
- return
- }
- assert.NoError(t, err)
-
- // Check truncated flag
- assert.Equal(t, tt.wantTrunc, truncated)
-
- // Check the result
- if tt.wantItems > 0 {
- assert.NotNil(t, contents)
- assert.Equal(t, tt.wantItems, len(contents))
- } else {
- assert.Empty(t, contents)
- }
- })
- }
-}
-
-func TestGithubClient_CreateFile(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- path string
- branch string
- message string
- content []byte
- wantErr error
- }{
- {
- name: "create file successfully",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- response := &github.RepositoryContentResponse{
- Content: &github.RepositoryContent{
- Name: github.Ptr("test.txt"),
- Path: github.Ptr("test.txt"),
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusCreated)
- require.NoError(t, json.NewEncoder(w).Encode(response))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Add test.txt",
- content: []byte("test content"),
- wantErr: nil,
- },
- {
- name: "file already exists",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusUnprocessableEntity,
- },
- Message: "File already exists",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "existing.txt",
- branch: "main",
- message: "Add existing.txt",
- content: []byte("test content"),
- wantErr: ErrResourceAlreadyExists,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Add test.txt",
- content: []byte("test content"),
- wantErr: errors.New("Service unavailable"),
- },
- {
- name: "not a github error response",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- _, err := w.Write([]byte("not a github error"))
- require.NoError(t, err)
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Add test.txt",
- content: []byte("test content"),
- wantErr: errors.New("500"),
- },
- {
- name: "default commit message",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Decode the request to verify the message
- body, err := io.ReadAll(r.Body)
- require.NoError(t, err)
-
- var reqData struct {
- Message string `json:"message"`
- }
- require.NoError(t, json.Unmarshal(body, &reqData))
- assert.Equal(t, "Create test.txt", reqData.Message)
-
- response := &github.RepositoryContentResponse{
- Content: &github.RepositoryContent{
- Name: github.Ptr("test.txt"),
- Path: github.Ptr("test.txt"),
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusCreated)
- require.NoError(t, json.NewEncoder(w).Encode(response))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "", // Empty message should use default
- content: []byte("test content"),
- wantErr: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- err := client.CreateFile(context.Background(), tt.owner, tt.repository, tt.path, tt.branch, tt.message, tt.content)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- } else {
- assert.NoError(t, err)
- }
- })
- }
-}
-
-func TestUpdateFile(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- path string
- branch string
- message string
- hash string
- content []byte
- wantErr error
- }{
- {
- name: "successful update",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Verify request body
- body, err := io.ReadAll(r.Body)
- require.NoError(t, err)
-
- var reqData struct {
- Message string `json:"message"`
- SHA string `json:"sha"`
- }
- require.NoError(t, json.Unmarshal(body, &reqData))
- assert.Equal(t, "Update test.txt", reqData.Message)
- assert.Equal(t, "abc123", reqData.SHA)
-
- response := &github.RepositoryContentResponse{
- Content: &github.RepositoryContent{
- Name: github.Ptr("test.txt"),
- Path: github.Ptr("test.txt"),
- SHA: github.Ptr("def456"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(response))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "", // Empty message should use default
- hash: "abc123",
- content: []byte("updated content"),
- wantErr: nil,
- },
- {
- name: "file not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Not Found"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "nonexistent.txt",
- branch: "main",
- message: "Update nonexistent file",
- hash: "abc123",
- content: []byte("content"),
- wantErr: ErrResourceNotFound,
- },
- {
- name: "mismatched hash",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusConflict)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "SHA mismatch"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Update with wrong hash",
- hash: "wrong-hash",
- content: []byte("content"),
- wantErr: ErrMismatchedHash,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Service unavailable"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Update during outage",
- hash: "abc123",
- content: []byte("content"),
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "other error",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Internal server error"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Update with server error",
- hash: "abc123",
- content: []byte("content"),
- wantErr: errors.New("Internal server error"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- err := client.UpdateFile(context.Background(), tt.owner, tt.repository, tt.path, tt.branch, tt.message, tt.hash, tt.content)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- if errors.Is(err, tt.wantErr) {
- assert.Equal(t, tt.wantErr, err)
- } else {
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- } else {
- assert.NoError(t, err)
- }
- })
- }
-}
-
-func TestGithubClient_DeleteFile(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- path string
- branch string
- message string
- hash string
- wantErr error
- }{
- {
- name: "delete file successfully",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- response := &github.RepositoryContentResponse{
- Content: nil,
- Commit: github.Commit{
- SHA: github.Ptr("def456"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(response))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Delete test.txt",
- hash: "abc123",
- wantErr: nil,
- },
- {
- name: "file not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Not Found",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "nonexistent.txt",
- branch: "main",
- message: "Delete nonexistent.txt",
- hash: "abc123",
- wantErr: ErrResourceNotFound,
- },
- {
- name: "mismatched hash",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusConflict)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusConflict,
- },
- Message: "Conflict",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Delete test.txt",
- hash: "wrong-hash",
- wantErr: ErrMismatchedHash,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Delete test.txt",
- hash: "abc123",
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "other error",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"message": "Internal server error"}))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "Delete with server error",
- hash: "abc123",
- wantErr: errors.New("Internal server error"),
- },
- {
- name: "default commit message",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.DeleteReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Decode the request to verify the message
- body, err := io.ReadAll(r.Body)
- require.NoError(t, err)
-
- var reqData struct {
- Message string `json:"message"`
- }
- require.NoError(t, json.Unmarshal(body, &reqData))
- assert.Equal(t, "Delete test.txt", reqData.Message)
-
- response := &github.RepositoryContentResponse{
- Content: nil,
- Commit: github.Commit{
- SHA: github.Ptr("def456"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(response))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- path: "test.txt",
- branch: "main",
- message: "",
- hash: "abc123",
- wantErr: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- err := client.DeleteFile(context.Background(), tt.owner, tt.repository, tt.path, tt.branch, tt.message, tt.hash)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- if errors.Is(err, tt.wantErr) {
- assert.Equal(t, tt.wantErr, err)
- } else {
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- } else {
- assert.NoError(t, err)
- }
- })
- }
-}
-
func TestGithubClient_GetCommits(t *testing.T) {
tests := []struct {
name string
@@ -1767,621 +359,6 @@ func TestGithubClient_GetCommits(t *testing.T) {
}
}
-func TestCompareCommits(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- base string
- head string
- wantFiles []CommitFile
- wantErr error
- }{
- {
- name: "successful comparison",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposCompareByOwnerByRepoByBasehead,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- files := []*github.CommitFile{
- {
- Filename: github.Ptr("file1.txt"),
- Status: github.Ptr("modified"),
- Additions: github.Ptr(10),
- Deletions: github.Ptr(5),
- Changes: github.Ptr(15),
- },
- {
- Filename: github.Ptr("file2.txt"),
- Status: github.Ptr("added"),
- Additions: github.Ptr(20),
- Deletions: github.Ptr(0),
- Changes: github.Ptr(20),
- },
- }
-
- require.NoError(t, json.NewEncoder(w).Encode(github.CommitsComparison{
- Files: files,
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- base: "main",
- head: "feature-branch",
- wantFiles: []CommitFile{
- &github.CommitFile{
- Filename: github.Ptr("file1.txt"),
- Status: github.Ptr("modified"),
- Additions: github.Ptr(10),
- Deletions: github.Ptr(5),
- Changes: github.Ptr(15),
- },
- &github.CommitFile{
- Filename: github.Ptr("file2.txt"),
- Status: github.Ptr("added"),
- Additions: github.Ptr(20),
- Deletions: github.Ptr(0),
- Changes: github.Ptr(20),
- },
- },
- wantErr: nil,
- },
- {
- name: "too many files",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposCompareByOwnerByRepoByBasehead,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- // Generate more files than the max limit
- files := make([]*github.CommitFile, maxCompareFiles+1)
- for i := 0; i < maxCompareFiles+1; i++ {
- files[i] = &github.CommitFile{
- Filename: github.Ptr(fmt.Sprintf("file%d.txt", i)),
- Status: github.Ptr("modified"),
- }
- }
-
- require.NoError(t, json.NewEncoder(w).Encode(github.CommitsComparison{
- Files: files,
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- base: "main",
- head: "feature-branch",
- wantFiles: nil,
- wantErr: fmt.Errorf("too many files changed between commits (more than %d)", maxCompareFiles),
- },
- {
- name: "resource not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposCompareByOwnerByRepoByBasehead,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Not found",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- base: "main",
- head: "feature-branch",
- wantFiles: nil,
- wantErr: ErrResourceNotFound,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposCompareByOwnerByRepoByBasehead,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- base: "main",
- head: "feature-branch",
- wantFiles: nil,
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "other error",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposCompareByOwnerByRepoByBasehead,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusInternalServerError,
- },
- Message: "Internal server error",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- base: "main",
- head: "feature-branch",
- wantFiles: nil,
- wantErr: errors.New("Internal server error"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- files, err := client.CompareCommits(context.Background(), tt.owner, tt.repository, tt.base, tt.head)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- if errors.Is(err, tt.wantErr) {
- assert.Equal(t, tt.wantErr, err)
- } else {
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- assert.Nil(t, files)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tt.wantFiles, files)
- }
- })
- }
-}
-
-func TestGetBranch(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- branchName string
- wantBranch Branch
- wantErr error
- }{
- {
- name: "get branch successfully",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- branch := &github.Branch{
- Name: github.Ptr("main"),
- Commit: &github.RepositoryCommit{
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(branch))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "main",
- wantBranch: Branch{
- Name: "main",
- Sha: "abc123",
- },
- wantErr: nil,
- },
- {
- name: "branch not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Branch not found",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "non-existent",
- wantBranch: Branch{},
- wantErr: ErrResourceNotFound,
- },
- {
- name: "service unavailable",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- },
- Message: "Service unavailable",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "main",
- wantBranch: Branch{},
- wantErr: ErrServiceUnavailable,
- },
- {
- name: "other error",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusInternalServerError,
- },
- Message: "Internal server error",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "main",
- wantBranch: Branch{},
- wantErr: errors.New("unexpected status code: 500 Internal Server Error"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- branch, err := client.GetBranch(context.Background(), tt.owner, tt.repository, tt.branchName)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- if errors.Is(err, tt.wantErr) {
- assert.Equal(t, tt.wantErr, err)
- } else {
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- assert.Equal(t, tt.wantBranch, branch)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tt.wantBranch, branch)
- }
- })
- }
-}
-
-func TestGithubClient_CreateBranch(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- sourceBranch string
- branchName string
- wantErr error
- }{
- {
- name: "successful branch creation",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // First call checks if branch exists (should return 404)
- if strings.Contains(r.URL.Path, "/new-branch") {
- w.WriteHeader(http.StatusNotFound)
- return
- }
-
- // Second call gets the source branch
- if strings.Contains(r.URL.Path, "/main") {
- branch := &github.Branch{
- Name: github.Ptr("main"),
- Commit: &github.RepositoryCommit{
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(branch))
- }
- }),
- ),
- mockhub.WithRequestMatchHandler(
- mockhub.PostReposGitRefsByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Verify the request body contains the correct reference
- body, err := io.ReadAll(r.Body)
- require.NoError(t, err)
- ref := struct {
- Ref string `json:"ref"`
- SHA string `json:"sha"`
- }{}
- require.NoError(t, json.Unmarshal(body, &ref))
- assert.Equal(t, "refs/heads/new-branch", ref.Ref)
- assert.Equal(t, "abc123", ref.SHA)
-
- w.WriteHeader(http.StatusCreated)
- require.NoError(t, json.NewEncoder(w).Encode(&github.Reference{
- Ref: github.Ptr("refs/heads/new-branch"),
- Object: &github.GitObject{
- SHA: github.Ptr("abc123"),
- },
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- sourceBranch: "main",
- branchName: "new-branch",
- wantErr: nil,
- },
- {
- name: "branch already exists",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Verify the request URL contains the correct owner, repo, and branch
- expectedPath := "/repos/test-owner/test-repo/branches/existing-branch"
- assert.True(t, strings.Contains(r.URL.Path, expectedPath),
- "Expected URL path to contain %s, got %s", expectedPath, r.URL.Path)
- // Branch exists check returns success
- branch := &github.Branch{
- Name: github.Ptr("existing-branch"),
- Commit: &github.RepositoryCommit{
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(branch))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- sourceBranch: "main",
- branchName: "existing-branch",
- wantErr: ErrResourceAlreadyExists,
- },
- {
- name: "source branch not found",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // First call checks if branch exists (should return 404)
- if strings.Contains(r.URL.Path, "/new-branch") {
- w.WriteHeader(http.StatusNotFound)
- return
- }
-
- // Second call gets the source branch (not found)
- if strings.Contains(r.URL.Path, "/nonexistent") {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Branch not found",
- }))
- }
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- sourceBranch: "nonexistent",
- branchName: "new-branch",
- wantErr: errors.New("get base branch"),
- },
- {
- name: "error creating branch ref",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // First call checks if branch exists (should return 404)
- if strings.Contains(r.URL.Path, "/new-branch") {
- w.WriteHeader(http.StatusNotFound)
- return
- }
-
- // Second call gets the source branch
- if strings.Contains(r.URL.Path, "/main") {
- branch := &github.Branch{
- Name: github.Ptr("main"),
- Commit: &github.RepositoryCommit{
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(branch))
- }
- }),
- ),
- mockhub.WithRequestMatchHandler(
- mockhub.PostReposGitRefsByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusInternalServerError,
- },
- Message: "Internal server error",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- sourceBranch: "main",
- branchName: "new-branch",
- wantErr: errors.New("create branch ref"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- err := client.CreateBranch(context.Background(), tt.owner, tt.repository, tt.sourceBranch, tt.branchName)
-
- // Check the error
- if tt.wantErr != nil {
- assert.Error(t, err)
- if errors.Is(err, tt.wantErr) {
- assert.Equal(t, tt.wantErr, err)
- } else {
- assert.Contains(t, err.Error(), tt.wantErr.Error())
- }
- } else {
- assert.NoError(t, err)
- }
- })
- }
-}
-
-func TestGithubClient_BranchExists(t *testing.T) {
- tests := []struct {
- name string
- mockHandler *http.Client
- owner string
- repository string
- branchName string
- want bool
- wantErr bool
- }{
- {
- name: "branch exists",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- branch := &github.Branch{
- Name: github.Ptr("existing-branch"),
- Commit: &github.RepositoryCommit{
- SHA: github.Ptr("abc123"),
- },
- }
- w.WriteHeader(http.StatusOK)
- require.NoError(t, json.NewEncoder(w).Encode(branch))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "existing-branch",
- want: true,
- wantErr: false,
- },
- {
- name: "branch does not exist",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusNotFound,
- },
- Message: "Branch not found",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "non-existent-branch",
- want: false,
- wantErr: false,
- },
- {
- name: "error response",
- mockHandler: mockhub.NewMockedHTTPClient(
- mockhub.WithRequestMatchHandler(
- mockhub.GetReposBranchesByOwnerByRepoByBranch,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
- Response: &http.Response{
- StatusCode: http.StatusInternalServerError,
- },
- Message: "Internal server error",
- }))
- }),
- ),
- ),
- owner: "test-owner",
- repository: "test-repo",
- branchName: "some-branch",
- want: false,
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock client
- factory := ProvideFactory()
- factory.Client = tt.mockHandler
- client := factory.New(context.Background(), "")
-
- // Call the method being tested
- got, err := client.BranchExists(context.Background(), tt.owner, tt.repository, tt.branchName)
-
- // Check the error
- if tt.wantErr {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- }
-
- // Check the result
- assert.Equal(t, tt.want, got)
- })
- }
-}
func TestGithubClient_ListWebhooks(t *testing.T) {
tests := []struct {
name string
@@ -3721,139 +1698,3 @@ func TestDefaultListOptions(t *testing.T) {
})
}
}
-
-func TestRealRepositoryContent(t *testing.T) {
- t.Run("IsDirectory", func(t *testing.T) {
- tests := []struct {
- name string
- repoType string
- want bool
- }{
- {
- name: "directory type",
- repoType: "dir",
- want: true,
- },
- {
- name: "file type",
- repoType: "file",
- want: false,
- },
- {
- name: "empty type",
- repoType: "",
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- repoType := tt.repoType
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Type: &repoType,
- },
- }
- got := content.IsDirectory()
- assert.Equal(t, tt.want, got)
- })
- }
- })
-
- t.Run("GetFileContent", func(t *testing.T) {
- fileContent := "test content"
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Content: &fileContent,
- },
- }
- got, err := content.GetFileContent()
- assert.NoError(t, err)
- assert.Equal(t, fileContent, got)
- })
-
- t.Run("IsSymlink", func(t *testing.T) {
- tests := []struct {
- name string
- target *string
- want bool
- }{
- {
- name: "is symlink",
- target: github.Ptr("target"),
- want: true,
- },
- {
- name: "not symlink",
- target: nil,
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Target: tt.target,
- },
- }
- got := content.IsSymlink()
- assert.Equal(t, tt.want, got)
- })
- }
- })
-
- t.Run("GetPath", func(t *testing.T) {
- path := "path/to/file"
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Path: &path,
- },
- }
- got := content.GetPath()
- assert.Equal(t, path, got)
- })
-
- t.Run("GetSHA", func(t *testing.T) {
- sha := "abc123"
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- SHA: &sha,
- },
- }
- got := content.GetSHA()
- assert.Equal(t, sha, got)
- })
-
- t.Run("GetSize", func(t *testing.T) {
- t.Run("with size field", func(t *testing.T) {
- size := 42
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Size: &size,
- },
- }
- got := content.GetSize()
- assert.Equal(t, int64(size), got)
- })
-
- t.Run("with content field", func(t *testing.T) {
- fileContent := "test content"
- content := realRepositoryContent{
- real: &github.RepositoryContent{
- Content: &fileContent,
- },
- }
- got := content.GetSize()
- assert.Equal(t, int64(len(fileContent)), got)
- })
-
- t.Run("with no size or content", func(t *testing.T) {
- content := realRepositoryContent{
- real: &github.RepositoryContent{},
- }
- got := content.GetSize()
- assert.Equal(t, int64(0), got)
- })
- })
-}
diff --git a/pkg/registry/apis/provisioning/repository/github/mock_client.go b/pkg/registry/apis/provisioning/repository/github/mock_client.go
index 2eb282b43ae..f70aa9f1359 100644
--- a/pkg/registry/apis/provisioning/repository/github/mock_client.go
+++ b/pkg/registry/apis/provisioning/repository/github/mock_client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package github
@@ -21,65 +21,6 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
-// BranchExists provides a mock function with given fields: ctx, owner, repository, branchName
-func (_m *MockClient) BranchExists(ctx context.Context, owner string, repository string, branchName string) (bool, error) {
- ret := _m.Called(ctx, owner, repository, branchName)
-
- if len(ret) == 0 {
- panic("no return value specified for BranchExists")
- }
-
- var r0 bool
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (bool, error)); ok {
- return rf(ctx, owner, repository, branchName)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string) bool); ok {
- r0 = rf(ctx, owner, repository, branchName)
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
- r1 = rf(ctx, owner, repository, branchName)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockClient_BranchExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BranchExists'
-type MockClient_BranchExists_Call struct {
- *mock.Call
-}
-
-// BranchExists is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - branchName string
-func (_e *MockClient_Expecter) BranchExists(ctx interface{}, owner interface{}, repository interface{}, branchName interface{}) *MockClient_BranchExists_Call {
- return &MockClient_BranchExists_Call{Call: _e.mock.On("BranchExists", ctx, owner, repository, branchName)}
-}
-
-func (_c *MockClient_BranchExists_Call) Run(run func(ctx context.Context, owner string, repository string, branchName string)) *MockClient_BranchExists_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
- })
- return _c
-}
-
-func (_c *MockClient_BranchExists_Call) Return(_a0 bool, _a1 error) *MockClient_BranchExists_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockClient_BranchExists_Call) RunAndReturn(run func(context.Context, string, string, string) (bool, error)) *MockClient_BranchExists_Call {
- _c.Call.Return(run)
- return _c
-}
-
// Commits provides a mock function with given fields: ctx, owner, repository, path, branch
func (_m *MockClient) Commits(ctx context.Context, owner string, repository string, path string, branch string) ([]Commit, error) {
ret := _m.Called(ctx, owner, repository, path, branch)
@@ -142,170 +83,6 @@ func (_c *MockClient_Commits_Call) RunAndReturn(run func(context.Context, string
return _c
}
-// CompareCommits provides a mock function with given fields: ctx, owner, repository, base, head
-func (_m *MockClient) CompareCommits(ctx context.Context, owner string, repository string, base string, head string) ([]CommitFile, error) {
- ret := _m.Called(ctx, owner, repository, base, head)
-
- if len(ret) == 0 {
- panic("no return value specified for CompareCommits")
- }
-
- var r0 []CommitFile
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) ([]CommitFile, error)); ok {
- return rf(ctx, owner, repository, base, head)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) []CommitFile); ok {
- r0 = rf(ctx, owner, repository, base, head)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).([]CommitFile)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {
- r1 = rf(ctx, owner, repository, base, head)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockClient_CompareCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareCommits'
-type MockClient_CompareCommits_Call struct {
- *mock.Call
-}
-
-// CompareCommits is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - base string
-// - head string
-func (_e *MockClient_Expecter) CompareCommits(ctx interface{}, owner interface{}, repository interface{}, base interface{}, head interface{}) *MockClient_CompareCommits_Call {
- return &MockClient_CompareCommits_Call{Call: _e.mock.On("CompareCommits", ctx, owner, repository, base, head)}
-}
-
-func (_c *MockClient_CompareCommits_Call) Run(run func(ctx context.Context, owner string, repository string, base string, head string)) *MockClient_CompareCommits_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
- })
- return _c
-}
-
-func (_c *MockClient_CompareCommits_Call) Return(_a0 []CommitFile, _a1 error) *MockClient_CompareCommits_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockClient_CompareCommits_Call) RunAndReturn(run func(context.Context, string, string, string, string) ([]CommitFile, error)) *MockClient_CompareCommits_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// CreateBranch provides a mock function with given fields: ctx, owner, repository, sourceBranch, branchName
-func (_m *MockClient) CreateBranch(ctx context.Context, owner string, repository string, sourceBranch string, branchName string) error {
- ret := _m.Called(ctx, owner, repository, sourceBranch, branchName)
-
- if len(ret) == 0 {
- panic("no return value specified for CreateBranch")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok {
- r0 = rf(ctx, owner, repository, sourceBranch, branchName)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockClient_CreateBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateBranch'
-type MockClient_CreateBranch_Call struct {
- *mock.Call
-}
-
-// CreateBranch is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - sourceBranch string
-// - branchName string
-func (_e *MockClient_Expecter) CreateBranch(ctx interface{}, owner interface{}, repository interface{}, sourceBranch interface{}, branchName interface{}) *MockClient_CreateBranch_Call {
- return &MockClient_CreateBranch_Call{Call: _e.mock.On("CreateBranch", ctx, owner, repository, sourceBranch, branchName)}
-}
-
-func (_c *MockClient_CreateBranch_Call) Run(run func(ctx context.Context, owner string, repository string, sourceBranch string, branchName string)) *MockClient_CreateBranch_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
- })
- return _c
-}
-
-func (_c *MockClient_CreateBranch_Call) Return(_a0 error) *MockClient_CreateBranch_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockClient_CreateBranch_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MockClient_CreateBranch_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// CreateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, content
-func (_m *MockClient) CreateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, content []byte) error {
- ret := _m.Called(ctx, owner, repository, path, branch, message, content)
-
- if len(ret) == 0 {
- panic("no return value specified for CreateFile")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, []byte) error); ok {
- r0 = rf(ctx, owner, repository, path, branch, message, content)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockClient_CreateFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFile'
-type MockClient_CreateFile_Call struct {
- *mock.Call
-}
-
-// CreateFile is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - path string
-// - branch string
-// - message string
-// - content []byte
-func (_e *MockClient_Expecter) CreateFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, content interface{}) *MockClient_CreateFile_Call {
- return &MockClient_CreateFile_Call{Call: _e.mock.On("CreateFile", ctx, owner, repository, path, branch, message, content)}
-}
-
-func (_c *MockClient_CreateFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, content []byte)) *MockClient_CreateFile_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].([]byte))
- })
- return _c
-}
-
-func (_c *MockClient_CreateFile_Call) Return(_a0 error) *MockClient_CreateFile_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockClient_CreateFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, []byte) error) *MockClient_CreateFile_Call {
- _c.Call.Return(run)
- return _c
-}
-
// CreatePullRequestComment provides a mock function with given fields: ctx, owner, repository, number, body
func (_m *MockClient) CreatePullRequestComment(ctx context.Context, owner string, repository string, number int, body string) error {
ret := _m.Called(ctx, owner, repository, number, body)
@@ -415,58 +192,6 @@ func (_c *MockClient_CreateWebhook_Call) RunAndReturn(run func(context.Context,
return _c
}
-// DeleteFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, hash
-func (_m *MockClient) DeleteFile(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string) error {
- ret := _m.Called(ctx, owner, repository, path, branch, message, hash)
-
- if len(ret) == 0 {
- panic("no return value specified for DeleteFile")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, string) error); ok {
- r0 = rf(ctx, owner, repository, path, branch, message, hash)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockClient_DeleteFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFile'
-type MockClient_DeleteFile_Call struct {
- *mock.Call
-}
-
-// DeleteFile is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - path string
-// - branch string
-// - message string
-// - hash string
-func (_e *MockClient_Expecter) DeleteFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, hash interface{}) *MockClient_DeleteFile_Call {
- return &MockClient_DeleteFile_Call{Call: _e.mock.On("DeleteFile", ctx, owner, repository, path, branch, message, hash)}
-}
-
-func (_c *MockClient_DeleteFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string)) *MockClient_DeleteFile_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].(string))
- })
- return _c
-}
-
-func (_c *MockClient_DeleteFile_Call) Return(_a0 error) *MockClient_DeleteFile_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockClient_DeleteFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, string) error) *MockClient_DeleteFile_Call {
- _c.Call.Return(run)
- return _c
-}
-
// DeleteWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) DeleteWebhook(ctx context.Context, owner string, repository string, webhookID int64) error {
ret := _m.Called(ctx, owner, repository, webhookID)
@@ -565,206 +290,6 @@ func (_c *MockClient_EditWebhook_Call) RunAndReturn(run func(context.Context, st
return _c
}
-// GetBranch provides a mock function with given fields: ctx, owner, repository, branchName
-func (_m *MockClient) GetBranch(ctx context.Context, owner string, repository string, branchName string) (Branch, error) {
- ret := _m.Called(ctx, owner, repository, branchName)
-
- if len(ret) == 0 {
- panic("no return value specified for GetBranch")
- }
-
- var r0 Branch
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (Branch, error)); ok {
- return rf(ctx, owner, repository, branchName)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string) Branch); ok {
- r0 = rf(ctx, owner, repository, branchName)
- } else {
- r0 = ret.Get(0).(Branch)
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
- r1 = rf(ctx, owner, repository, branchName)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockClient_GetBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBranch'
-type MockClient_GetBranch_Call struct {
- *mock.Call
-}
-
-// GetBranch is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - branchName string
-func (_e *MockClient_Expecter) GetBranch(ctx interface{}, owner interface{}, repository interface{}, branchName interface{}) *MockClient_GetBranch_Call {
- return &MockClient_GetBranch_Call{Call: _e.mock.On("GetBranch", ctx, owner, repository, branchName)}
-}
-
-func (_c *MockClient_GetBranch_Call) Run(run func(ctx context.Context, owner string, repository string, branchName string)) *MockClient_GetBranch_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
- })
- return _c
-}
-
-func (_c *MockClient_GetBranch_Call) Return(_a0 Branch, _a1 error) *MockClient_GetBranch_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockClient_GetBranch_Call) RunAndReturn(run func(context.Context, string, string, string) (Branch, error)) *MockClient_GetBranch_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// GetContents provides a mock function with given fields: ctx, owner, repository, path, ref
-func (_m *MockClient) GetContents(ctx context.Context, owner string, repository string, path string, ref string) (RepositoryContent, []RepositoryContent, error) {
- ret := _m.Called(ctx, owner, repository, path, ref)
-
- if len(ret) == 0 {
- panic("no return value specified for GetContents")
- }
-
- var r0 RepositoryContent
- var r1 []RepositoryContent
- var r2 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) (RepositoryContent, []RepositoryContent, error)); ok {
- return rf(ctx, owner, repository, path, ref)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) RepositoryContent); ok {
- r0 = rf(ctx, owner, repository, path, ref)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(RepositoryContent)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) []RepositoryContent); ok {
- r1 = rf(ctx, owner, repository, path, ref)
- } else {
- if ret.Get(1) != nil {
- r1 = ret.Get(1).([]RepositoryContent)
- }
- }
-
- if rf, ok := ret.Get(2).(func(context.Context, string, string, string, string) error); ok {
- r2 = rf(ctx, owner, repository, path, ref)
- } else {
- r2 = ret.Error(2)
- }
-
- return r0, r1, r2
-}
-
-// MockClient_GetContents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContents'
-type MockClient_GetContents_Call struct {
- *mock.Call
-}
-
-// GetContents is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - path string
-// - ref string
-func (_e *MockClient_Expecter) GetContents(ctx interface{}, owner interface{}, repository interface{}, path interface{}, ref interface{}) *MockClient_GetContents_Call {
- return &MockClient_GetContents_Call{Call: _e.mock.On("GetContents", ctx, owner, repository, path, ref)}
-}
-
-func (_c *MockClient_GetContents_Call) Run(run func(ctx context.Context, owner string, repository string, path string, ref string)) *MockClient_GetContents_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
- })
- return _c
-}
-
-func (_c *MockClient_GetContents_Call) Return(fileContents RepositoryContent, dirContents []RepositoryContent, err error) *MockClient_GetContents_Call {
- _c.Call.Return(fileContents, dirContents, err)
- return _c
-}
-
-func (_c *MockClient_GetContents_Call) RunAndReturn(run func(context.Context, string, string, string, string) (RepositoryContent, []RepositoryContent, error)) *MockClient_GetContents_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// GetTree provides a mock function with given fields: ctx, owner, repository, basePath, ref, recursive
-func (_m *MockClient) GetTree(ctx context.Context, owner string, repository string, basePath string, ref string, recursive bool) ([]RepositoryContent, bool, error) {
- ret := _m.Called(ctx, owner, repository, basePath, ref, recursive)
-
- if len(ret) == 0 {
- panic("no return value specified for GetTree")
- }
-
- var r0 []RepositoryContent
- var r1 bool
- var r2 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, bool) ([]RepositoryContent, bool, error)); ok {
- return rf(ctx, owner, repository, basePath, ref, recursive)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, bool) []RepositoryContent); ok {
- r0 = rf(ctx, owner, repository, basePath, ref, recursive)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).([]RepositoryContent)
- }
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string, bool) bool); ok {
- r1 = rf(ctx, owner, repository, basePath, ref, recursive)
- } else {
- r1 = ret.Get(1).(bool)
- }
-
- if rf, ok := ret.Get(2).(func(context.Context, string, string, string, string, bool) error); ok {
- r2 = rf(ctx, owner, repository, basePath, ref, recursive)
- } else {
- r2 = ret.Error(2)
- }
-
- return r0, r1, r2
-}
-
-// MockClient_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree'
-type MockClient_GetTree_Call struct {
- *mock.Call
-}
-
-// GetTree is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - basePath string
-// - ref string
-// - recursive bool
-func (_e *MockClient_Expecter) GetTree(ctx interface{}, owner interface{}, repository interface{}, basePath interface{}, ref interface{}, recursive interface{}) *MockClient_GetTree_Call {
- return &MockClient_GetTree_Call{Call: _e.mock.On("GetTree", ctx, owner, repository, basePath, ref, recursive)}
-}
-
-func (_c *MockClient_GetTree_Call) Run(run func(ctx context.Context, owner string, repository string, basePath string, ref string, recursive bool)) *MockClient_GetTree_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(bool))
- })
- return _c
-}
-
-func (_c *MockClient_GetTree_Call) Return(entries []RepositoryContent, truncated bool, err error) *MockClient_GetTree_Call {
- _c.Call.Return(entries, truncated, err)
- return _c
-}
-
-func (_c *MockClient_GetTree_Call) RunAndReturn(run func(context.Context, string, string, string, string, bool) ([]RepositoryContent, bool, error)) *MockClient_GetTree_Call {
- _c.Call.Return(run)
- return _c
-}
-
// GetWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) GetWebhook(ctx context.Context, owner string, repository string, webhookID int64) (WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository, webhookID)
@@ -824,52 +349,6 @@ func (_c *MockClient_GetWebhook_Call) RunAndReturn(run func(context.Context, str
return _c
}
-// IsAuthenticated provides a mock function with given fields: ctx
-func (_m *MockClient) IsAuthenticated(ctx context.Context) error {
- ret := _m.Called(ctx)
-
- if len(ret) == 0 {
- panic("no return value specified for IsAuthenticated")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context) error); ok {
- r0 = rf(ctx)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockClient_IsAuthenticated_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAuthenticated'
-type MockClient_IsAuthenticated_Call struct {
- *mock.Call
-}
-
-// IsAuthenticated is a helper method to define mock.On call
-// - ctx context.Context
-func (_e *MockClient_Expecter) IsAuthenticated(ctx interface{}) *MockClient_IsAuthenticated_Call {
- return &MockClient_IsAuthenticated_Call{Call: _e.mock.On("IsAuthenticated", ctx)}
-}
-
-func (_c *MockClient_IsAuthenticated_Call) Run(run func(ctx context.Context)) *MockClient_IsAuthenticated_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context))
- })
- return _c
-}
-
-func (_c *MockClient_IsAuthenticated_Call) Return(_a0 error) *MockClient_IsAuthenticated_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockClient_IsAuthenticated_Call) RunAndReturn(run func(context.Context) error) *MockClient_IsAuthenticated_Call {
- _c.Call.Return(run)
- return _c
-}
-
// ListPullRequestFiles provides a mock function with given fields: ctx, owner, repository, number
func (_m *MockClient) ListPullRequestFiles(ctx context.Context, owner string, repository string, number int) ([]CommitFile, error) {
ret := _m.Called(ctx, owner, repository, number)
@@ -991,117 +470,6 @@ func (_c *MockClient_ListWebhooks_Call) RunAndReturn(run func(context.Context, s
return _c
}
-// RepoExists provides a mock function with given fields: ctx, owner, repository
-func (_m *MockClient) RepoExists(ctx context.Context, owner string, repository string) (bool, error) {
- ret := _m.Called(ctx, owner, repository)
-
- if len(ret) == 0 {
- panic("no return value specified for RepoExists")
- }
-
- var r0 bool
- var r1 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok {
- return rf(ctx, owner, repository)
- }
- if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
- r0 = rf(ctx, owner, repository)
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
- r1 = rf(ctx, owner, repository)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockClient_RepoExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoExists'
-type MockClient_RepoExists_Call struct {
- *mock.Call
-}
-
-// RepoExists is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-func (_e *MockClient_Expecter) RepoExists(ctx interface{}, owner interface{}, repository interface{}) *MockClient_RepoExists_Call {
- return &MockClient_RepoExists_Call{Call: _e.mock.On("RepoExists", ctx, owner, repository)}
-}
-
-func (_c *MockClient_RepoExists_Call) Run(run func(ctx context.Context, owner string, repository string)) *MockClient_RepoExists_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string))
- })
- return _c
-}
-
-func (_c *MockClient_RepoExists_Call) Return(_a0 bool, _a1 error) *MockClient_RepoExists_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockClient_RepoExists_Call) RunAndReturn(run func(context.Context, string, string) (bool, error)) *MockClient_RepoExists_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// UpdateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, hash, content
-func (_m *MockClient) UpdateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string, content []byte) error {
- ret := _m.Called(ctx, owner, repository, path, branch, message, hash, content)
-
- if len(ret) == 0 {
- panic("no return value specified for UpdateFile")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, string, []byte) error); ok {
- r0 = rf(ctx, owner, repository, path, branch, message, hash, content)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockClient_UpdateFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFile'
-type MockClient_UpdateFile_Call struct {
- *mock.Call
-}
-
-// UpdateFile is a helper method to define mock.On call
-// - ctx context.Context
-// - owner string
-// - repository string
-// - path string
-// - branch string
-// - message string
-// - hash string
-// - content []byte
-func (_e *MockClient_Expecter) UpdateFile(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}, message interface{}, hash interface{}, content interface{}) *MockClient_UpdateFile_Call {
- return &MockClient_UpdateFile_Call{Call: _e.mock.On("UpdateFile", ctx, owner, repository, path, branch, message, hash, content)}
-}
-
-func (_c *MockClient_UpdateFile_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string, content []byte)) *MockClient_UpdateFile_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string), args[5].(string), args[6].(string), args[7].([]byte))
- })
- return _c
-}
-
-func (_c *MockClient_UpdateFile_Call) Return(_a0 error) *MockClient_UpdateFile_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockClient_UpdateFile_Call) RunAndReturn(run func(context.Context, string, string, string, string, string, string, []byte) error) *MockClient_UpdateFile_Call {
- _c.Call.Return(run)
- return _c
-}
-
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
diff --git a/pkg/registry/apis/provisioning/repository/github/mock_commit_file.go b/pkg/registry/apis/provisioning/repository/github/mock_commit_file.go
index 29e91059f90..68d3d2044f0 100644
--- a/pkg/registry/apis/provisioning/repository/github/mock_commit_file.go
+++ b/pkg/registry/apis/provisioning/repository/github/mock_commit_file.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package github
diff --git a/pkg/registry/apis/provisioning/repository/github/mock_repository_content.go b/pkg/registry/apis/provisioning/repository/github/mock_repository_content.go
deleted file mode 100644
index 4121dc99359..00000000000
--- a/pkg/registry/apis/provisioning/repository/github/mock_repository_content.go
+++ /dev/null
@@ -1,312 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package github
-
-import mock "github.com/stretchr/testify/mock"
-
-// MockRepositoryContent is an autogenerated mock type for the RepositoryContent type
-type MockRepositoryContent struct {
- mock.Mock
-}
-
-type MockRepositoryContent_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockRepositoryContent) EXPECT() *MockRepositoryContent_Expecter {
- return &MockRepositoryContent_Expecter{mock: &_m.Mock}
-}
-
-// GetFileContent provides a mock function with no fields
-func (_m *MockRepositoryContent) GetFileContent() (string, error) {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for GetFileContent")
- }
-
- var r0 string
- var r1 error
- if rf, ok := ret.Get(0).(func() (string, error)); ok {
- return rf()
- }
- if rf, ok := ret.Get(0).(func() string); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(string)
- }
-
- if rf, ok := ret.Get(1).(func() error); ok {
- r1 = rf()
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockRepositoryContent_GetFileContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFileContent'
-type MockRepositoryContent_GetFileContent_Call struct {
- *mock.Call
-}
-
-// GetFileContent is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) GetFileContent() *MockRepositoryContent_GetFileContent_Call {
- return &MockRepositoryContent_GetFileContent_Call{Call: _e.mock.On("GetFileContent")}
-}
-
-func (_c *MockRepositoryContent_GetFileContent_Call) Run(run func()) *MockRepositoryContent_GetFileContent_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_GetFileContent_Call) Return(_a0 string, _a1 error) *MockRepositoryContent_GetFileContent_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockRepositoryContent_GetFileContent_Call) RunAndReturn(run func() (string, error)) *MockRepositoryContent_GetFileContent_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// GetPath provides a mock function with no fields
-func (_m *MockRepositoryContent) GetPath() string {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for GetPath")
- }
-
- var r0 string
- if rf, ok := ret.Get(0).(func() string); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(string)
- }
-
- return r0
-}
-
-// MockRepositoryContent_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath'
-type MockRepositoryContent_GetPath_Call struct {
- *mock.Call
-}
-
-// GetPath is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) GetPath() *MockRepositoryContent_GetPath_Call {
- return &MockRepositoryContent_GetPath_Call{Call: _e.mock.On("GetPath")}
-}
-
-func (_c *MockRepositoryContent_GetPath_Call) Run(run func()) *MockRepositoryContent_GetPath_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_GetPath_Call) Return(_a0 string) *MockRepositoryContent_GetPath_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepositoryContent_GetPath_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetPath_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// GetSHA provides a mock function with no fields
-func (_m *MockRepositoryContent) GetSHA() string {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for GetSHA")
- }
-
- var r0 string
- if rf, ok := ret.Get(0).(func() string); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(string)
- }
-
- return r0
-}
-
-// MockRepositoryContent_GetSHA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSHA'
-type MockRepositoryContent_GetSHA_Call struct {
- *mock.Call
-}
-
-// GetSHA is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) GetSHA() *MockRepositoryContent_GetSHA_Call {
- return &MockRepositoryContent_GetSHA_Call{Call: _e.mock.On("GetSHA")}
-}
-
-func (_c *MockRepositoryContent_GetSHA_Call) Run(run func()) *MockRepositoryContent_GetSHA_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_GetSHA_Call) Return(_a0 string) *MockRepositoryContent_GetSHA_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepositoryContent_GetSHA_Call) RunAndReturn(run func() string) *MockRepositoryContent_GetSHA_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// GetSize provides a mock function with no fields
-func (_m *MockRepositoryContent) GetSize() int64 {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for GetSize")
- }
-
- var r0 int64
- if rf, ok := ret.Get(0).(func() int64); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(int64)
- }
-
- return r0
-}
-
-// MockRepositoryContent_GetSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSize'
-type MockRepositoryContent_GetSize_Call struct {
- *mock.Call
-}
-
-// GetSize is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) GetSize() *MockRepositoryContent_GetSize_Call {
- return &MockRepositoryContent_GetSize_Call{Call: _e.mock.On("GetSize")}
-}
-
-func (_c *MockRepositoryContent_GetSize_Call) Run(run func()) *MockRepositoryContent_GetSize_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_GetSize_Call) Return(_a0 int64) *MockRepositoryContent_GetSize_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepositoryContent_GetSize_Call) RunAndReturn(run func() int64) *MockRepositoryContent_GetSize_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// IsDirectory provides a mock function with no fields
-func (_m *MockRepositoryContent) IsDirectory() bool {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for IsDirectory")
- }
-
- var r0 bool
- if rf, ok := ret.Get(0).(func() bool); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- return r0
-}
-
-// MockRepositoryContent_IsDirectory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsDirectory'
-type MockRepositoryContent_IsDirectory_Call struct {
- *mock.Call
-}
-
-// IsDirectory is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) IsDirectory() *MockRepositoryContent_IsDirectory_Call {
- return &MockRepositoryContent_IsDirectory_Call{Call: _e.mock.On("IsDirectory")}
-}
-
-func (_c *MockRepositoryContent_IsDirectory_Call) Run(run func()) *MockRepositoryContent_IsDirectory_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_IsDirectory_Call) Return(_a0 bool) *MockRepositoryContent_IsDirectory_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepositoryContent_IsDirectory_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsDirectory_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// IsSymlink provides a mock function with no fields
-func (_m *MockRepositoryContent) IsSymlink() bool {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for IsSymlink")
- }
-
- var r0 bool
- if rf, ok := ret.Get(0).(func() bool); ok {
- r0 = rf()
- } else {
- r0 = ret.Get(0).(bool)
- }
-
- return r0
-}
-
-// MockRepositoryContent_IsSymlink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsSymlink'
-type MockRepositoryContent_IsSymlink_Call struct {
- *mock.Call
-}
-
-// IsSymlink is a helper method to define mock.On call
-func (_e *MockRepositoryContent_Expecter) IsSymlink() *MockRepositoryContent_IsSymlink_Call {
- return &MockRepositoryContent_IsSymlink_Call{Call: _e.mock.On("IsSymlink")}
-}
-
-func (_c *MockRepositoryContent_IsSymlink_Call) Run(run func()) *MockRepositoryContent_IsSymlink_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockRepositoryContent_IsSymlink_Call) Return(_a0 bool) *MockRepositoryContent_IsSymlink_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepositoryContent_IsSymlink_Call) RunAndReturn(run func() bool) *MockRepositoryContent_IsSymlink_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockRepositoryContent creates a new instance of MockRepositoryContent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockRepositoryContent(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockRepositoryContent {
- mock := &MockRepositoryContent{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/repository/github/repository.go b/pkg/registry/apis/provisioning/repository/github/repository.go
new file mode 100644
index 00000000000..4199a225d5a
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/repository.go
@@ -0,0 +1,240 @@
+package github
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
+type githubRepository struct {
+ gitRepo git.GitRepository
+ config *provisioning.Repository
+ gh Client // assumes github.com base URL
+
+ owner string
+ repo string
+}
+
+// GithubRepository is an interface that combines all repository capabilities
+// needed for GitHub repositories.
+
+//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter
+type GithubRepository interface {
+ repository.Repository
+ repository.Versioned
+ repository.Writer
+ repository.Reader
+ repository.RepositoryWithURLs
+ repository.StageableRepository
+ Owner() string
+ Repo() string
+ Client() Client
+}
+
+func NewGitHub(
+ ctx context.Context,
+ config *provisioning.Repository,
+ gitRepo git.GitRepository,
+ factory *Factory,
+ token string,
+) (GithubRepository, error) {
+ owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
+ if err != nil {
+ return nil, fmt.Errorf("parse owner and repo: %w", err)
+ }
+
+ return &githubRepository{
+ config: config,
+ gitRepo: gitRepo,
+ gh: factory.New(ctx, token), // TODO, baseURL from config
+ owner: owner,
+ repo: repo,
+ }, nil
+}
+
+func (r *githubRepository) Config() *provisioning.Repository {
+ return r.gitRepo.Config()
+}
+
+func (r *githubRepository) Owner() string {
+ return r.owner
+}
+
+func (r *githubRepository) Repo() string {
+ return r.repo
+}
+
+func (r *githubRepository) Client() Client {
+ return r.gh
+}
+
+// Validate implements provisioning.Repository.
+func (r *githubRepository) Validate() (list field.ErrorList) {
+ cfg := r.gitRepo.Config()
+ gh := cfg.Spec.GitHub
+ if gh == nil {
+ list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
+ return list
+ }
+ if gh.URL == "" {
+ list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
+ } else {
+ _, _, err := ParseOwnerRepoGithub(gh.URL)
+ if err != nil {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
+ } else if !strings.HasPrefix(gh.URL, "https://github.com/") {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
+ }
+ }
+
+ if len(list) > 0 {
+ return list
+ }
+
+ return r.gitRepo.Validate()
+}
+
+func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) {
+ parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git"))
+ if e != nil {
+ err = e
+ return
+ }
+ parts := strings.Split(parsed.Path, "/")
+ if len(parts) < 3 {
+ err = fmt.Errorf("unable to parse repo+owner from url")
+ return
+ }
+ return parts[1], parts[2], nil
+}
+
+// Test implements provisioning.Repository.
+func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
+ url := r.config.Spec.GitHub.URL
+ _, _, err := ParseOwnerRepoGithub(url)
+ if err != nil {
+ return repository.FromFieldError(field.Invalid(
+ field.NewPath("spec", "github", "url"), url, err.Error())), nil
+ }
+
+ return r.gitRepo.Test(ctx)
+}
+
+// ReadResource implements provisioning.Repository.
+func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) {
+ return r.gitRepo.Read(ctx, filePath, ref)
+}
+
+func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
+ return r.gitRepo.ReadTree(ctx, ref)
+}
+
+func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
+ return r.gitRepo.Create(ctx, path, ref, data, comment)
+}
+
+func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
+ return r.gitRepo.Update(ctx, path, ref, data, comment)
+}
+
+func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
+ return r.gitRepo.Write(ctx, path, ref, data, message)
+}
+
+func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
+ return r.gitRepo.Delete(ctx, path, ref, comment)
+}
+
+func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+ commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
+ if err != nil {
+ if errors.Is(err, ErrResourceNotFound) {
+ return nil, repository.ErrFileNotFound
+ }
+
+ return nil, fmt.Errorf("get commits: %w", err)
+ }
+
+ ret := make([]provisioning.HistoryItem, 0, len(commits))
+ for _, commit := range commits {
+ authors := make([]provisioning.Author, 0)
+ if commit.Author != nil {
+ authors = append(authors, provisioning.Author{
+ Name: commit.Author.Name,
+ Username: commit.Author.Username,
+ AvatarURL: commit.Author.AvatarURL,
+ })
+ }
+
+ if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
+ authors = append(authors, provisioning.Author{
+ Name: commit.Committer.Name,
+ Username: commit.Committer.Username,
+ AvatarURL: commit.Committer.AvatarURL,
+ })
+ }
+
+ ret = append(ret, provisioning.HistoryItem{
+ Ref: commit.Ref,
+ Message: commit.Message,
+ Authors: authors,
+ CreatedAt: commit.CreatedAt.UnixMilli(),
+ })
+ }
+
+ return ret, nil
+}
+
+func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
+ return r.gitRepo.LatestRef(ctx)
+}
+
+func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) {
+ return r.gitRepo.CompareFiles(ctx, base, ref)
+}
+
+// ResourceURLs implements RepositoryWithURLs.
+func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) {
+ cfg := r.config.Spec.GitHub
+ if file.Path == "" || cfg == nil {
+ return nil, nil
+ }
+
+ ref := file.Ref
+ if ref == "" {
+ ref = cfg.Branch
+ }
+
+ urls := &provisioning.ResourceURLs{
+ RepositoryURL: cfg.URL,
+ SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
+ }
+
+ if ref != cfg.Branch {
+ urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
+
+ // Create a new pull request
+ urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
+ }
+
+ return urls, nil
+}
+
+func (r *githubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
+ return r.gitRepo.Stage(ctx, opts)
+}
diff --git a/pkg/registry/apis/provisioning/repository/github/repository_test.go b/pkg/registry/apis/provisioning/repository/github/repository_test.go
new file mode 100644
index 00000000000..374ec001a8b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/repository_test.go
@@ -0,0 +1,1014 @@
+package github
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ field "k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
+)
+
+func TestNewGitHub(t *testing.T) {
+ tests := []struct {
+ name string
+ config *provisioning.Repository
+ token string
+ expectedError string
+ expectedOwner string
+ expectedRepo string
+ }{
+ {
+ name: "successful creation",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ },
+ },
+ },
+ token: "token123",
+ expectedError: "",
+ expectedOwner: "grafana",
+ expectedRepo: "grafana",
+ },
+ {
+ name: "invalid URL format",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "invalid-url",
+ Branch: "main",
+ },
+ },
+ },
+ token: "token123",
+ expectedError: "parse owner and repo",
+ },
+ {
+ name: "URL with .git extension",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana.git",
+ Branch: "main",
+ },
+ },
+ },
+ token: "token123",
+ expectedError: "",
+ expectedOwner: "grafana",
+ expectedRepo: "grafana",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ factory := ProvideFactory()
+ factory.Client = http.DefaultClient
+
+ gitRepo := git.NewMockGitRepository(t)
+
+ // Call the function under test
+ repo, err := NewGitHub(
+ context.Background(),
+ tt.config,
+ gitRepo,
+ factory,
+ tt.token,
+ )
+
+ // Check results
+ if tt.expectedError != "" {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedError)
+ assert.Nil(t, repo)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, repo)
+ assert.Equal(t, tt.expectedOwner, repo.Owner())
+ assert.Equal(t, tt.expectedRepo, repo.Repo())
+ concreteRepo, ok := repo.(*githubRepository)
+ require.True(t, ok)
+ assert.Equal(t, gitRepo, concreteRepo.gitRepo)
+ }
+ })
+ }
+}
+
+func TestParseOwnerRepoGithub(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ expectedOwner string
+ expectedRepo string
+ expectedError string
+ }{
+ {
+ name: "valid GitHub URL",
+ url: "https://github.com/grafana/grafana",
+ expectedOwner: "grafana",
+ expectedRepo: "grafana",
+ },
+ {
+ name: "valid GitHub URL with .git",
+ url: "https://github.com/grafana/grafana.git",
+ expectedOwner: "grafana",
+ expectedRepo: "grafana",
+ },
+ {
+ name: "invalid URL format",
+ url: "invalid-url",
+ expectedError: "parse",
+ },
+ {
+ name: "missing repo name",
+ url: "https://github.com/grafana",
+ expectedError: "unable to parse repo+owner from url",
+ },
+ {
+ name: "URL with special characters",
+ url: "https://github.com/user%",
+ expectedError: "parse",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ owner, repo, err := ParseOwnerRepoGithub(tt.url)
+
+ if tt.expectedError != "" {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedError)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedOwner, owner)
+ assert.Equal(t, tt.expectedRepo, repo)
+ }
+ })
+ }
+}
+
+func TestGitHubRepositoryValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config *provisioning.Repository
+ mockSetup func(m *git.MockGitRepository)
+ expectedErrors int
+ errorFields []string
+ }{
+ {
+ name: "valid configuration",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ Token: "valid-token",
+ Path: "dashboards",
+ },
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ Token: "valid-token",
+ Path: "dashboards",
+ },
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ },
+ expectedErrors: 0,
+ },
+ {
+ name: "missing GitHub config",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: nil,
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: nil,
+ },
+ })
+ },
+ expectedErrors: 1,
+ errorFields: []string{"spec.github"},
+ },
+ {
+ name: "missing URL",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ })
+ },
+ expectedErrors: 1,
+ errorFields: []string{"spec.github.url"},
+ },
+ {
+ name: "invalid URL format",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "invalid-url",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "invalid-url",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ })
+ },
+ expectedErrors: 1,
+ errorFields: []string{"spec.github.url"},
+ },
+ {
+ name: "non-GitHub URL",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://gitlab.com/grafana/grafana",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://gitlab.com/grafana/grafana",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ })
+ },
+ expectedErrors: 1,
+ errorFields: []string{"spec.github.url"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ if tt.mockSetup != nil {
+ tt.mockSetup(mockGitRepo)
+ }
+
+ repo := &githubRepository{
+ config: tt.config,
+ gitRepo: mockGitRepo,
+ }
+
+ errors := repo.Validate()
+
+ assert.Equal(t, tt.expectedErrors, len(errors), "Expected %d errors, got %d, errors: %v", tt.expectedErrors, len(errors), errors)
+
+ if tt.expectedErrors > 0 {
+ errorFields := make([]string, 0, len(errors))
+ for _, err := range errors {
+ errorFields = append(errorFields, err.Field)
+ }
+ for _, expectedField := range tt.errorFields {
+ assert.Contains(t, errorFields, expectedField, "Expected error for field %s", expectedField)
+ }
+ }
+
+ mockGitRepo.AssertExpectations(t)
+ })
+ }
+}
+
+func TestGitHubRepositoryTest(t *testing.T) {
+ tests := []struct {
+ name string
+ config *provisioning.Repository
+ mockSetup func(m *git.MockGitRepository)
+ expectedResult *provisioning.TestResults
+ expectedError error
+ }{
+ {
+ name: "successful test",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ },
+ mockSetup: func(m *git.MockGitRepository) {
+ m.On("Test", mock.Anything).Return(&provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ }, nil)
+ },
+ expectedResult: &provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ },
+ },
+ {
+ name: "invalid URL",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "invalid-url",
+ Branch: "main",
+ Token: "valid-token",
+ },
+ },
+ },
+ mockSetup: func(_ *git.MockGitRepository) {
+ // No mock calls expected as validation fails first
+ },
+ expectedResult: &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []provisioning.ErrorDetails{{
+ Type: metav1.CauseTypeFieldValueInvalid,
+ Field: "spec.github.url",
+ Detail: "parse \"invalid-url\": invalid URI for request",
+ }},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ if tt.mockSetup != nil {
+ tt.mockSetup(mockGitRepo)
+ }
+
+ repo := &githubRepository{
+ config: tt.config,
+ gitRepo: mockGitRepo,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ result, err := repo.Test(context.Background())
+
+ if tt.expectedError != nil {
+ assert.Error(t, err)
+ assert.Equal(t, tt.expectedError.Error(), err.Error())
+ } else {
+ assert.NoError(t, err)
+ }
+
+ if tt.expectedResult != nil {
+ assert.Equal(t, tt.expectedResult.Code, result.Code)
+ assert.Equal(t, tt.expectedResult.Success, result.Success)
+ if len(tt.expectedResult.Errors) > 0 {
+ assert.Equal(t, len(tt.expectedResult.Errors), len(result.Errors))
+ for i, expectedError := range tt.expectedResult.Errors {
+ assert.Equal(t, expectedError.Type, result.Errors[i].Type)
+ assert.Equal(t, expectedError.Field, result.Errors[i].Field)
+ assert.Contains(t, result.Errors[i].Detail, "parse")
+ }
+ }
+ }
+
+ mockGitRepo.AssertExpectations(t)
+ })
+ }
+}
+
+func TestGitHubRepositoryHistory(t *testing.T) {
+ tests := []struct {
+ name string
+ config *provisioning.Repository
+ path string
+ ref string
+ mockSetup func(m *MockClient)
+ expectedResult []provisioning.HistoryItem
+ expectedError error
+ }{
+ {
+ name: "successful history retrieval",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "dashboard.json",
+ ref: "main",
+ mockSetup: func(m *MockClient) {
+ commits := []Commit{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Author: &CommitAuthor{
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ Committer: &CommitAuthor{
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
+ },
+ }
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/dashboard.json", "main").
+ Return(commits, nil)
+ },
+ expectedResult: []provisioning.HistoryItem{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Authors: []provisioning.Author{
+ {
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
+ },
+ },
+ },
+ {
+ name: "file not found",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "nonexistent.json",
+ ref: "main",
+ mockSetup: func(m *MockClient) {
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/nonexistent.json", "main").
+ Return(nil, ErrResourceNotFound)
+ },
+ expectedError: repository.ErrFileNotFound,
+ },
+ {
+ name: "use default branch when ref is empty",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "dashboard.json",
+ ref: "",
+ mockSetup: func(m *MockClient) {
+ commits := []Commit{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Author: &CommitAuthor{
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
+ },
+ }
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/dashboard.json", "main").
+ Return(commits, nil)
+ },
+ expectedResult: []provisioning.HistoryItem{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Authors: []provisioning.Author{
+ {
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
+ },
+ },
+ },
+ {
+ name: "committer different from author",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "dashboard.json",
+ ref: "main",
+ mockSetup: func(m *MockClient) {
+ commits := []Commit{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Author: &CommitAuthor{
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ Committer: &CommitAuthor{
+ Name: "Jane Smith",
+ Username: "janesmith",
+ AvatarURL: "https://example.com/avatar2.png",
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
+ },
+ }
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/dashboard.json", "main").
+ Return(commits, nil)
+ },
+ expectedResult: []provisioning.HistoryItem{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Authors: []provisioning.Author{
+ {
+ Name: "John Doe",
+ Username: "johndoe",
+ AvatarURL: "https://example.com/avatar1.png",
+ },
+ {
+ Name: "Jane Smith",
+ Username: "janesmith",
+ AvatarURL: "https://example.com/avatar2.png",
+ },
+ },
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
+ },
+ },
+ },
+ {
+ name: "commit with no author",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "dashboard.json",
+ ref: "main",
+ mockSetup: func(m *MockClient) {
+ commits := []Commit{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Author: nil,
+ Committer: nil,
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
+ },
+ }
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/dashboard.json", "main").
+ Return(commits, nil)
+ },
+ expectedResult: []provisioning.HistoryItem{
+ {
+ Ref: "abc123",
+ Message: "Update dashboard",
+ Authors: []provisioning.Author{},
+ CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
+ },
+ },
+ },
+ {
+ name: "other API error",
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ Branch: "main",
+ Path: "dashboards",
+ },
+ },
+ },
+ path: "dashboard.json",
+ ref: "main",
+ mockSetup: func(m *MockClient) {
+ m.On("Commits", mock.Anything, "grafana", "grafana", "dashboards/dashboard.json", "main").
+ Return(nil, errors.New("API error"))
+ },
+ expectedError: errors.New("get commits: API error"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockClient := NewMockClient(t)
+ if tt.mockSetup != nil {
+ tt.mockSetup(mockClient)
+ }
+
+ repo := &githubRepository{
+ config: tt.config,
+ gh: mockClient,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ history, err := repo.History(context.Background(), tt.path, tt.ref)
+
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ var statusErr *apierrors.StatusError
+ if errors.As(tt.expectedError, &statusErr) {
+ var actualStatusErr *apierrors.StatusError
+ require.True(t, errors.As(err, &actualStatusErr))
+ require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
+ } else {
+ require.Equal(t, tt.expectedError.Error(), err.Error())
+ }
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tt.expectedResult, history)
+ }
+
+ mockClient.AssertExpectations(t)
+ })
+ }
+}
+
+func TestGitHubRepositoryResourceURLs(t *testing.T) {
+ tests := []struct {
+ name string
+ file *repository.FileInfo
+ config *provisioning.Repository
+ expectedURLs *provisioning.ResourceURLs
+ expectedError error
+ }{
+ {
+ name: "file with ref",
+ file: &repository.FileInfo{
+ Path: "dashboards/test.json",
+ Ref: "feature-branch",
+ },
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ },
+ },
+ },
+ expectedURLs: &provisioning.ResourceURLs{
+ RepositoryURL: "https://github.com/grafana/grafana",
+ SourceURL: "https://github.com/grafana/grafana/blob/feature-branch/dashboards/test.json",
+ CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch",
+ NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana",
+ },
+ },
+ {
+ name: "file without ref uses default branch",
+ file: &repository.FileInfo{
+ Path: "dashboards/test.json",
+ Ref: "",
+ },
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ },
+ },
+ },
+ expectedURLs: &provisioning.ResourceURLs{
+ RepositoryURL: "https://github.com/grafana/grafana",
+ SourceURL: "https://github.com/grafana/grafana/blob/main/dashboards/test.json",
+ },
+ },
+ {
+ name: "empty path returns nil",
+ file: &repository.FileInfo{
+ Path: "",
+ Ref: "feature-branch",
+ },
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ },
+ },
+ },
+ expectedURLs: nil,
+ },
+ {
+ name: "nil github config returns nil",
+ file: &repository.FileInfo{
+ Path: "dashboards/test.json",
+ Ref: "feature-branch",
+ },
+ config: &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: nil,
+ },
+ },
+ expectedURLs: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &githubRepository{
+ config: tt.config,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ urls, err := repo.ResourceURLs(context.Background(), tt.file)
+
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ require.Equal(t, tt.expectedError.Error(), err.Error())
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tt.expectedURLs, urls)
+ }
+ })
+ }
+}
+
+// Test simple delegation functions
+func TestGitHubRepositoryDelegation(t *testing.T) {
+ ctx := context.Background()
+
+ config := &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ Token: "test-token",
+ },
+ },
+ }
+
+ t.Run("Config delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ mockGitRepo.On("Config").Return(config)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result := repo.Config()
+ assert.Equal(t, config, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Read delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ expectedFileInfo := &repository.FileInfo{
+ Path: "test.yaml",
+ Data: []byte("test data"),
+ Ref: "main",
+ Hash: "abc123",
+ }
+ mockGitRepo.On("Read", ctx, "test.yaml", "main").Return(expectedFileInfo, nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result, err := repo.Read(ctx, "test.yaml", "main")
+ require.NoError(t, err)
+ assert.Equal(t, expectedFileInfo, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("ReadTree delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ expectedEntries := []repository.FileTreeEntry{
+ {Path: "file1.yaml", Size: 100, Hash: "hash1", Blob: true},
+ }
+ mockGitRepo.On("ReadTree", ctx, "main").Return(expectedEntries, nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result, err := repo.ReadTree(ctx, "main")
+ require.NoError(t, err)
+ assert.Equal(t, expectedEntries, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Create delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ data := []byte("test content")
+ mockGitRepo.On("Create", ctx, "new-file.yaml", "main", data, "Create new file").Return(nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ err := repo.Create(ctx, "new-file.yaml", "main", data, "Create new file")
+ require.NoError(t, err)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Update delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ data := []byte("updated content")
+ mockGitRepo.On("Update", ctx, "existing-file.yaml", "main", data, "Update file").Return(nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ err := repo.Update(ctx, "existing-file.yaml", "main", data, "Update file")
+ require.NoError(t, err)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Write delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ data := []byte("file content")
+ mockGitRepo.On("Write", ctx, "file.yaml", "main", data, "Write file").Return(nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ err := repo.Write(ctx, "file.yaml", "main", data, "Write file")
+ require.NoError(t, err)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Delete delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ mockGitRepo.On("Delete", ctx, "file.yaml", "main", "Delete file").Return(nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ err := repo.Delete(ctx, "file.yaml", "main", "Delete file")
+ require.NoError(t, err)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("LatestRef delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ expectedRef := "abc123def456"
+ mockGitRepo.On("LatestRef", ctx).Return(expectedRef, nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result, err := repo.LatestRef(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, expectedRef, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("CompareFiles delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ expectedChanges := []repository.VersionedFileChange{
+ {
+ Action: repository.FileActionCreated,
+ Path: "new-file.yaml",
+ Ref: "feature-branch",
+ },
+ }
+ mockGitRepo.On("CompareFiles", ctx, "main", "feature-branch").Return(expectedChanges, nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result, err := repo.CompareFiles(ctx, "main", "feature-branch")
+ require.NoError(t, err)
+ assert.Equal(t, expectedChanges, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+
+ t.Run("Stage delegates to git repo", func(t *testing.T) {
+ mockGitRepo := git.NewMockGitRepository(t)
+ mockStagedRepo := repository.NewMockStagedRepository(t)
+ opts := repository.StageOptions{
+ PushOnWrites: true,
+ Timeout: 10 * time.Second,
+ }
+ mockGitRepo.On("Stage", ctx, opts).Return(mockStagedRepo, nil)
+
+ repo := &githubRepository{
+ config: config,
+ gitRepo: mockGitRepo,
+ }
+
+ result, err := repo.Stage(ctx, opts)
+ require.NoError(t, err)
+ assert.Equal(t, mockStagedRepo, result)
+ mockGitRepo.AssertExpectations(t)
+ })
+}
+
+// Test GitHub-specific accessor methods
+func TestGitHubRepositoryAccessors(t *testing.T) {
+ config := &provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/grafana",
+ Branch: "main",
+ Token: "test-token",
+ },
+ },
+ }
+
+ t.Run("Owner returns correct owner", func(t *testing.T) {
+ repo := &githubRepository{
+ config: config,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ result := repo.Owner()
+ assert.Equal(t, "grafana", result)
+ })
+
+ t.Run("Repo returns correct repo", func(t *testing.T) {
+ repo := &githubRepository{
+ config: config,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ result := repo.Repo()
+ assert.Equal(t, "grafana", result)
+ })
+
+ t.Run("Client returns correct client", func(t *testing.T) {
+ mockClient := NewMockClient(t)
+
+ repo := &githubRepository{
+ config: config,
+ gh: mockClient,
+ owner: "grafana",
+ repo: "grafana",
+ }
+
+ result := repo.Client()
+ assert.Equal(t, mockClient, result)
+ })
+}
diff --git a/pkg/registry/apis/provisioning/repository/github_test.go b/pkg/registry/apis/provisioning/repository/github_test.go
deleted file mode 100644
index 3c0e499713e..00000000000
--- a/pkg/registry/apis/provisioning/repository/github_test.go
+++ /dev/null
@@ -1,3143 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- field "k8s.io/apimachinery/pkg/util/validation/field"
-
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
-)
-
-func TestNewGitHub(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- setupMock func(m *secrets.MockService)
- expectedError string
- expectedRepo *githubRepository
- }{
- {
- name: "successful creation with token",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Token: "token123",
- Branch: "main",
- },
- },
- },
- setupMock: func(m *secrets.MockService) {
- // No mock calls expected since we're using the token directly
- },
- expectedError: "",
- expectedRepo: &githubRepository{
- owner: "grafana",
- repo: "grafana",
- },
- },
- {
- name: "successful creation with encrypted token",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- EncryptedToken: []byte("encrypted-token"),
- Branch: "main",
- },
- },
- },
- setupMock: func(m *secrets.MockService) {
- m.On("Decrypt", mock.Anything, []byte("encrypted-token")).
- Return([]byte("decrypted-token"), nil)
- },
- expectedError: "",
- expectedRepo: &githubRepository{
- owner: "grafana",
- repo: "grafana",
- },
- },
- {
- name: "error decrypting token",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- EncryptedToken: []byte("encrypted-token"),
- Branch: "main",
- },
- },
- },
- setupMock: func(m *secrets.MockService) {
- m.On("Decrypt", mock.Anything, []byte("encrypted-token")).
- Return(nil, fmt.Errorf("decryption error"))
- },
- expectedError: "decrypt token: decryption error",
- },
- {
- name: "invalid URL format",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "invalid-url",
- Token: "token123",
- Branch: "main",
- },
- },
- },
- setupMock: func(m *secrets.MockService) {
- // No mock calls expected
- },
- expectedError: "parse owner and repo",
- expectedRepo: &githubRepository{
- owner: "",
- repo: "",
- },
- },
- {
- name: "URL with .git extension",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana.git",
- Token: "token123",
- Branch: "main",
- },
- },
- },
- setupMock: func(m *secrets.MockService) {
- // No mock calls expected
- },
- expectedError: "",
- expectedRepo: &githubRepository{
- owner: "grafana",
- repo: "grafana",
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup mocks
- mockSecrets := secrets.NewMockService(t)
- if tt.setupMock != nil {
- tt.setupMock(mockSecrets)
- }
-
- factory := pgh.ProvideFactory()
- factory.Client = http.DefaultClient
-
- // Create a mock clone function
- cloneFn := func(ctx context.Context, opts CloneOptions) (ClonedRepository, error) {
- return nil, nil
- }
-
- // Call the function under test
- repo, err := NewGitHub(
- context.Background(),
- tt.config,
- factory,
- mockSecrets,
- cloneFn,
- )
-
- // Check results
- if tt.expectedError != "" {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tt.expectedError)
- assert.Nil(t, repo)
- } else {
- require.NoError(t, err)
- require.NotNil(t, repo)
- assert.Equal(t, tt.expectedRepo.owner, repo.Owner())
- assert.Equal(t, tt.expectedRepo.repo, repo.Repo())
- assert.Equal(t, tt.config, repo.Config())
- concreteRepo, ok := repo.(*githubRepository)
- require.True(t, ok)
- assert.Equal(t, mockSecrets, concreteRepo.secrets)
- assert.NotNil(t, concreteRepo.cloneFn)
- }
-
- // Verify all mock expectations were met
- mockSecrets.AssertExpectations(t)
- })
- }
-}
-
-func TestIsValidGitBranchName(t *testing.T) {
- tests := []struct {
- name string
- branch string
- expected bool
- }{
- {"Valid branch name", "feature/add-tests", true},
- {"Valid branch name with numbers", "feature/123-add-tests", true},
- {"Valid branch name with dots", "feature.add.tests", true},
- {"Valid branch name with hyphens", "feature-add-tests", true},
- {"Valid branch name with underscores", "feature_add_tests", true},
- {"Valid branch name with mixed characters", "feature/add_tests-123", true},
- {"Starts with /", "/feature", false},
- {"Ends with /", "feature/", false},
- {"Ends with .", "feature.", false},
- {"Ends with space", "feature ", false},
- {"Contains consecutive slashes", "feature//branch", false},
- {"Contains consecutive dots", "feature..branch", false},
- {"Contains @{", "feature@{branch", false},
- {"Contains invalid character ~", "feature~branch", false},
- {"Contains invalid character ^", "feature^branch", false},
- {"Contains invalid character :", "feature:branch", false},
- {"Contains invalid character ?", "feature?branch", false},
- {"Contains invalid character *", "feature*branch", false},
- {"Contains invalid character [", "feature[branch", false},
- {"Contains invalid character ]", "feature]branch", false},
- {"Contains invalid character \\", "feature\\branch", false},
- {"Empty branch name", "", false},
- {"Only whitespace", " ", false},
- {"Single valid character", "a", true},
- {"Ends with .lock", "feature.lock", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- assert.Equal(t, tt.expected, IsValidGitBranchName(tt.branch))
- })
- }
-}
-func TestGitHubRepositoryValidate(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- expectedErrors int
- errorFields []string
- }{
- {
- name: "Valid configuration",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 0,
- },
- {
- name: "Valid configuration with .git suffix",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana.git",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 0,
- },
- {
- name: "Missing GitHub config",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: nil,
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github"},
- },
- {
- name: "Missing URL",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.url"},
- },
- {
- name: "Invalid URL format",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "invalid-url",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.url"},
- },
- {
- name: "Fail to parse URL",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/user%",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.url"},
- },
- {
- name: "URL not starting with https://github.com/",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://gitlab.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.url"},
- },
- {
- name: "Missing repo name",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana",
- Branch: "main",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.url"},
- },
- {
- name: "Missing branch",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.branch"},
- },
- {
- name: "Invalid branch name",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "feature//invalid",
- Token: "valid-token",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.branch"},
- },
- {
- name: "Missing token",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "",
- Path: "dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.token"},
- },
- {
- name: "Unsafe path",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- Path: "../dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.prefix"},
- },
- {
- name: "Absolute path",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- Path: "/dashboards",
- },
- },
- },
- expectedErrors: 1,
- errorFields: []string{"spec.github.prefix"},
- },
- {
- name: "Multiple errors",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "",
- Branch: "",
- Token: "",
- Path: "/dashboards",
- },
- },
- },
- expectedErrors: 4,
- errorFields: []string{"spec.github.url", "spec.github.branch", "spec.github.token", "spec.github.prefix"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a GitHub repository with the test config
- repo := &githubRepository{
- config: tt.config,
- }
-
- // Validate the configuration
- errors := repo.Validate()
-
- // Check the number of errors
- assert.Equal(t, tt.expectedErrors, len(errors), "Expected %d errors, got %d, errors: %v", tt.expectedErrors, len(errors), errors)
-
- // If we expect errors, check that they are for the right fields
- if tt.expectedErrors > 0 {
- errorFields := make([]string, 0, len(errors))
- for _, err := range errors {
- errorFields = append(errorFields, err.Field)
- }
- for _, expectedField := range tt.errorFields {
- assert.Contains(t, errorFields, expectedField, "Expected error for field %s", expectedField)
- }
- }
- })
- }
-}
-
-func TestGitHubRepository_Test(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- mockSetup func(t *testing.T, client *pgh.MockClient)
- expectedResult *provisioning.TestResults
- expectedError error
- }{
- {
- name: "Authentication failure",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "invalid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(errors.New("authentication failed"))
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseTypeFieldValueInvalid,
- Field: "spec.github.token",
- Detail: "authentication failed",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Invalid URL",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/invalid",
- Branch: "main",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseTypeFieldValueInvalid,
- Field: "spec.github.url",
- Detail: "unable to parse repo+owner from url",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Failed to check if repo exists",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/nonexistent",
- Branch: "main",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- client.On("RepoExists", mock.Anything, "grafana", "nonexistent").Return(false, errors.New("failed to check if repo exists"))
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseType(field.ErrorTypeInvalid),
- Field: "spec.github.url",
- Detail: "failed to check if repo exists",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Repository does not exist",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/nonexistent",
- Branch: "main",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- client.On("RepoExists", mock.Anything, "grafana", "nonexistent").Return(false, nil)
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseType(field.ErrorTypeNotFound),
- Field: "spec.github.url",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Branch does not exist",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "nonexistent-branch",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "nonexistent-branch").Return(false, nil)
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseType(field.ErrorTypeNotFound),
- Field: "spec.github.branch",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Branch check error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(false, errors.New("API rate limit exceeded"))
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusBadRequest,
- Success: false,
- Errors: []provisioning.ErrorDetails{{
- Type: metav1.CauseType(field.ErrorTypeInvalid),
- Field: "spec.github.branch",
- Detail: "API rate limit exceeded",
- }},
- },
- expectedError: nil,
- },
- {
- name: "Successful test",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- Token: "valid-token",
- },
- },
- },
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("IsAuthenticated", mock.Anything).Return(nil)
- client.On("RepoExists", mock.Anything, "grafana", "grafana").Return(true, nil)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- },
- expectedResult: &provisioning.TestResults{
- Code: http.StatusOK,
- Success: true,
- },
- expectedError: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // If the config has a different URL, parse and set the owner/repo
- if tt.config.Spec.GitHub.URL != "https://github.com/grafana/grafana" {
- owner, githubRepo, _ := ParseOwnerRepoGithub(tt.config.Spec.GitHub.URL)
- repo.owner = owner
- repo.repo = githubRepo
- }
-
- // Test the repository
- result, err := repo.Test(context.Background())
-
- // Check the error
- if tt.expectedError != nil {
- assert.Error(t, err)
- assert.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- assert.NoError(t, err)
- }
-
- // Check the result
- if tt.expectedResult != nil {
- assert.Equal(t, tt.expectedResult.Code, result.Code)
- assert.Equal(t, tt.expectedResult.Success, result.Success)
-
- if len(tt.expectedResult.Errors) > 0 {
- assert.Equal(t, len(tt.expectedResult.Errors), len(result.Errors))
-
- for i, expectedError := range tt.expectedResult.Errors {
- assert.Equal(t, expectedError.Type, result.Errors[i].Type)
- assert.Equal(t, expectedError.Field, result.Errors[i].Field)
- assert.Equal(t, expectedError.Detail, result.Errors[i].Detail)
- }
- }
- }
-
- // Verify all expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestReadTree(t *testing.T) {
- tests := []struct {
- name string
- path string
- ref string
- expectedRef string
- tree []pgh.RepositoryContent
- expected []FileTreeEntry
- getTreeErr error
- truncated bool
- expectedError error
- }{
- {name: "empty ref", ref: "", expectedRef: "develop", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}},
- {name: "unknown error to get tree", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, getTreeErr: errors.New("unknown error"), expectedError: errors.New("get tree: unknown error")},
- {name: "tree not found error", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, getTreeErr: pgh.ErrResourceNotFound, expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "tree not found; ref=develop",
- Code: http.StatusNotFound,
- },
- }},
- {name: "tree truncated", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, truncated: true, expectedError: errors.New("tree truncated")},
- {name: "empty tree", ref: "develop", expectedRef: "develop", tree: []pgh.RepositoryContent{}, expected: []FileTreeEntry{}},
- {name: "single file", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent {
- content := pgh.NewMockRepositoryContent(t)
- content.EXPECT().GetPath().Return("file.txt")
- content.EXPECT().GetSize().Return(int64(100))
- content.EXPECT().GetSHA().Return("abc123")
- content.EXPECT().IsDirectory().Return(false)
- return []pgh.RepositoryContent{content}
- }(), expected: []FileTreeEntry{
- {Path: "file.txt", Size: 100, Hash: "abc123", Blob: true},
- }},
- {name: "single directory", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent {
- content := pgh.NewMockRepositoryContent(t)
- content.EXPECT().GetPath().Return("dir")
- content.EXPECT().IsDirectory().Return(true)
- content.EXPECT().GetSize().Return(int64(0))
- content.EXPECT().GetSHA().Return("")
-
- return []pgh.RepositoryContent{content}
- }(), expected: []FileTreeEntry{
- {Path: "dir/", Blob: false},
- }},
- {name: "mixed content", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent {
- file1 := pgh.NewMockRepositoryContent(t)
- file1.EXPECT().GetPath().Return("file1.txt")
- file1.EXPECT().GetSize().Return(int64(100))
- file1.EXPECT().GetSHA().Return("abc123")
- file1.EXPECT().IsDirectory().Return(false)
-
- dir := pgh.NewMockRepositoryContent(t)
- dir.EXPECT().GetPath().Return("dir")
- dir.EXPECT().IsDirectory().Return(true)
- dir.EXPECT().GetSize().Return(int64(0))
- dir.EXPECT().GetSHA().Return("")
-
- file2 := pgh.NewMockRepositoryContent(t)
- file2.EXPECT().GetPath().Return("file2.txt")
- file2.EXPECT().GetSize().Return(int64(200))
- file2.EXPECT().GetSHA().Return("def456")
- file2.EXPECT().IsDirectory().Return(false)
-
- return []pgh.RepositoryContent{file1, dir, file2}
- }(), expected: []FileTreeEntry{
- {Path: "file1.txt", Size: 100, Hash: "abc123", Blob: true},
- {Path: "dir/", Blob: false},
- {Path: "file2.txt", Size: 200, Hash: "def456", Blob: true},
- }},
- {name: "with path prefix", ref: "develop", expectedRef: "develop", tree: func() []pgh.RepositoryContent {
- file := pgh.NewMockRepositoryContent(t)
- file.EXPECT().GetPath().Return("file.txt")
- file.EXPECT().GetSize().Return(int64(100))
- file.EXPECT().GetSHA().Return("abc123")
- file.EXPECT().IsDirectory().Return(false)
-
- dir := pgh.NewMockRepositoryContent(t)
- dir.EXPECT().GetPath().Return("dir")
- dir.EXPECT().GetSize().Return(int64(0))
- dir.EXPECT().GetSHA().Return("")
- dir.EXPECT().IsDirectory().Return(true)
-
- return []pgh.RepositoryContent{file, dir}
- }(), expected: []FileTreeEntry{
- {Path: "file.txt", Size: 100, Hash: "abc123", Blob: true},
- {Path: "dir/", Blob: false},
- }},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ghMock := pgh.NewMockClient(t)
- gh := &githubRepository{
- owner: "owner",
- repo: "repo",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: tt.path,
- Branch: "develop",
- },
- },
- },
- gh: ghMock,
- }
-
- ghMock.On("GetTree", mock.Anything, "owner", "repo", tt.path, tt.expectedRef, true).Return(tt.tree, tt.truncated, tt.getTreeErr)
- tree, err := gh.ReadTree(context.Background(), tt.ref)
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- require.Equal(t, tt.expected, tree)
- }
- })
- }
-}
-
-func TestGitHubRepository_Read(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- filePath string
- ref string
- mockSetup func(t *testing.T, client *pgh.MockClient)
- expectedResult *FileInfo
- expectedError error
- }{
- {
- name: "File found successfully",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "main",
- },
- },
- },
- filePath: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("file content", nil)
- fileContent.EXPECT().GetSHA().Return("abc123")
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main").
- Return(fileContent, nil, nil)
- },
- expectedResult: &FileInfo{
- Path: "dashboard.json",
- Ref: "main",
- Data: []byte("file content"),
- Hash: "abc123",
- },
- expectedError: nil,
- },
- {
- name: "Directory found successfully",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "main",
- },
- },
- },
- filePath: "dashboards",
- ref: "main",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- dirContent := []pgh.RepositoryContent{
- // Directory contents not used in this test
- }
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboards", "main").
- Return(nil, dirContent, nil)
- },
- expectedResult: &FileInfo{
- Path: "dashboards",
- Ref: "main",
- },
- expectedError: nil,
- },
- {
- name: "File not found",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "main",
- },
- },
- },
- filePath: "nonexistent.json",
- ref: "main",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/nonexistent.json", "main").
- Return(nil, nil, pgh.ErrResourceNotFound)
- },
- expectedResult: nil,
- expectedError: ErrFileNotFound,
- },
- {
- name: "Error getting file content",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "main",
- },
- },
- },
- filePath: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("", errors.New("failed to decode content"))
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main").
- Return(fileContent, nil, nil)
- },
- expectedResult: nil,
- expectedError: fmt.Errorf("get content: %w", errors.New("failed to decode content")),
- },
- {
- name: "GitHub API error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "main",
- },
- },
- },
- filePath: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "main").
- Return(nil, nil, errors.New("API rate limit exceeded"))
- },
- expectedResult: nil,
- expectedError: fmt.Errorf("get contents: %w", errors.New("API rate limit exceeded")),
- },
- {
- name: "Use default branch when ref is empty",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "configs",
- Branch: "develop",
- },
- },
- },
- filePath: "dashboard.json",
- ref: "", // Empty ref should use default branch
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("file content", nil)
- fileContent.EXPECT().GetSHA().Return("abc123")
- client.On("GetContents", mock.Anything, "grafana", "grafana", "configs/dashboard.json", "develop").
- Return(fileContent, nil, nil)
- },
- expectedResult: &FileInfo{
- Path: "dashboard.json",
- Ref: "develop",
- Data: []byte("file content"),
- Hash: "abc123",
- },
- expectedError: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Read method
- result, err := repo.Read(context.Background(), tt.filePath, tt.ref)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- var statusErr *apierrors.StatusError
- if errors.As(tt.expectedError, &statusErr) {
- var actualStatusErr *apierrors.StatusError
- require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
- require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
- require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
- } else {
- require.Equal(t, tt.expectedError.Error(), err.Error())
- }
- } else {
- require.NoError(t, err)
- }
-
- // Check the result
- if tt.expectedResult != nil {
- require.Equal(t, tt.expectedResult.Path, result.Path)
- require.Equal(t, tt.expectedResult.Ref, result.Ref)
- require.Equal(t, tt.expectedResult.Data, result.Data)
- require.Equal(t, tt.expectedResult.Hash, result.Hash)
- } else {
- require.Nil(t, result)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_Create(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- path string
- ref string
- data []byte
- comment string
- mockSetup func(t *testing.T, mockClient *pgh.MockClient)
- expectedError error
- }{
- {
- name: "successful file creation",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature-branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "feature-branch", "Add new dashboard", []byte("dashboard content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "create with default branch",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", "Add new dashboard", []byte("dashboard content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "branch already exists error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature-branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(pgh.ErrResourceAlreadyExists)
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusConflict,
- Message: "branch already exists",
- },
- },
- },
- {
- name: "branch does not exist error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature-branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(fmt.Errorf("failed to create branch"))
- },
- expectedError: fmt.Errorf("create branch: %w", fmt.Errorf("failed to create branch")),
- },
- {
- name: "branch does not exist but it's created",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature-branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "feature-branch", "Add new dashboard", []byte("dashboard content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "invalid branch name",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature//branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- // No mock expectations needed as validation should fail before any GitHub API calls
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusBadRequest,
- Message: "invalid branch name",
- },
- },
- },
- {
- name: "branch exists check fails",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature-branch",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature-branch").Return(false, fmt.Errorf("failed to check branch"))
- },
- expectedError: fmt.Errorf("check branch exists: %w", fmt.Errorf("failed to check branch")),
- },
- {
- name: "file already exists",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- data: []byte("dashboard content"),
- comment: "Add new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main", "Add new dashboard", []byte("dashboard content")).Return(pgh.ErrResourceAlreadyExists)
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "file already exists",
- Code: http.StatusConflict,
- },
- },
- },
- {
- name: "create directory with .keep file",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboards/",
- ref: "main",
- data: nil,
- comment: "Add dashboards directory",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/.keep", "main", "Add dashboards directory", []byte{}).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "error when providing data for directory",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboards/",
- ref: "main",
- data: []byte("some data"),
- comment: "Add dashboards directory",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- },
- expectedError: apierrors.NewBadRequest("data cannot be provided for a directory"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Create method
- err := repo.Create(context.Background(), tt.path, tt.ref, tt.data, tt.comment)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- var statusErr *apierrors.StatusError
- if errors.As(tt.expectedError, &statusErr) {
- var actualStatusErr *apierrors.StatusError
- require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
- require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
- require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
- } else {
- require.Equal(t, tt.expectedError.Error(), err.Error())
- }
- } else {
- require.NoError(t, err)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_Update(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- path string
- ref string
- data []byte
- comment string
- mockSetup func(t *testing.T, client *pgh.MockClient)
- expectedError error
- }{
- {
- name: "Successfully update file",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch").
- Return(fileContent, nil, nil)
- client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch",
- "Update test file", "abc123", []byte("updated content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "Use default branch when ref is empty",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "main").
- Return(fileContent, nil, nil)
- client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "main",
- "Update test file", "abc123", []byte("updated content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "Branch does not exist",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(errors.New("failed to create branch"))
- },
- expectedError: errors.New("create branch: failed to create branch"),
- },
- {
- name: "Invalid branch name",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "invalid//branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- // No mock calls expected
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusBadRequest,
- Message: "invalid branch name",
- },
- },
- },
- {
- name: "Branch exists check fails",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, errors.New("failed to check branch"))
- },
- expectedError: errors.New("check branch exists: failed to check branch"),
- },
- {
- name: "Branch does not exist but it's created successfully",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(nil)
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch").
- Return(fileContent, nil, nil)
- client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch",
- "Update test file", "abc123", []byte("updated content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "Branch already exists error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(false, nil)
- client.On("CreateBranch", mock.Anything, "grafana", "grafana", "main", "feature-branch").Return(pgh.ErrResourceAlreadyExists)
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Code: http.StatusConflict,
- Message: "branch already exists",
- },
- },
- },
- {
- name: "File not found",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch").
- Return(nil, nil, pgh.ErrResourceNotFound)
- },
- expectedError: &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "file not found",
- Code: http.StatusNotFound,
- },
- },
- },
- {
- name: "Error getting file contents",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch").
- Return(nil, nil, errors.New("API error"))
- },
- expectedError: errors.New("get content before file update: API error"),
- },
- {
- name: "Cannot update directory",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/directory",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test directory",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
-
- // Create a directory file
- dirFile := pgh.NewMockRepositoryContent(t)
- dirFile.EXPECT().IsDirectory().Return(true)
-
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/directory", "feature-branch").
- Return(dirFile, nil, nil)
- },
- expectedError: apierrors.NewBadRequest("cannot update a directory"),
- },
- {
- name: "Error updating file",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "base/path",
- },
- },
- },
- path: "test/file.txt",
- ref: "feature-branch",
- data: []byte("updated content"),
- comment: "Update test file",
- mockSetup: func(t *testing.T, client *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
- client.On("BranchExists", mock.Anything, "grafana", "grafana", "feature-branch").Return(true, nil)
- client.On("GetContents", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch").
- Return(fileContent, nil, nil)
- client.On("UpdateFile", mock.Anything, "grafana", "grafana", "base/path/test/file.txt", "feature-branch",
- "Update test file", "abc123", []byte("updated content")).Return(errors.New("update failed"))
- },
- expectedError: errors.New("update file: update failed"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Update method
- err := repo.Update(context.Background(), tt.path, tt.ref, tt.data, tt.comment)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- var statusErr *apierrors.StatusError
- if errors.As(tt.expectedError, &statusErr) {
- var actualStatusErr *apierrors.StatusError
- require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
- require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
- require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
- } else {
- require.Equal(t, tt.expectedError.Error(), err.Error())
- }
- } else {
- require.NoError(t, err)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_Write(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- path string
- ref string
- data []byte
- message string
- mockSetup func(t *testing.T, mockClient *pgh.MockClient)
- expectedError error
- }{
- {
- name: "write to existing file (update)",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- data: []byte("updated content"),
- message: "Update dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("existing content", nil)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
-
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Update dashboard", "abc123", []byte("updated content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "write to non-existing file (create)",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "new-dashboard.json",
- ref: "main",
- data: []byte("new content"),
- message: "Create new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main").
- Return(nil, nil, pgh.ErrResourceNotFound)
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main",
- "Create new dashboard", []byte("new content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "write with default branch",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "",
- data: []byte("content"),
- message: "Update dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("existing content", nil)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
-
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Update dashboard", "abc123", []byte("content")).Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "error checking if file exists",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- data: []byte("content"),
- message: "Update dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(nil, nil, errors.New("connection error"))
- },
- expectedError: errors.New("check if file exists before writing: get contents: connection error"),
- },
- {
- name: "error during update",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- data: []byte("updated content"),
- message: "Update dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().GetFileContent().Return("existing content", nil)
- fileContent.EXPECT().GetSHA().Return("abc123")
- fileContent.EXPECT().IsDirectory().Return(false)
-
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().UpdateFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Update dashboard", "abc123", []byte("updated content")).Return(errors.New("update failed"))
- },
- expectedError: errors.New("update file: update failed"),
- },
- {
- name: "error during create",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "new-dashboard.json",
- ref: "main",
- data: []byte("new content"),
- message: "Create new dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main").
- Return(nil, nil, pgh.ErrResourceNotFound)
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().CreateFile(mock.Anything, "grafana", "grafana", "grafana/new-dashboard.json", "main",
- "Create new dashboard", []byte("new content")).Return(errors.New("create failed"))
- },
- expectedError: errors.New("create failed"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Write method
- err := repo.Write(context.Background(), tt.path, tt.ref, tt.data, tt.message)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_Delete(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- path string
- ref string
- comment string
- mockSetup func(t *testing.T, mockClient *pgh.MockClient)
- expectedError error
- }{
- {
- name: "delete existing file",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().IsDirectory().Return(false)
- fileContent.EXPECT().GetSHA().Return("abc123")
-
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Delete dashboard", "abc123").Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "delete with default branch",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().IsDirectory().Return(false)
- fileContent.EXPECT().GetSHA().Return("abc123")
-
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Delete dashboard", "abc123").Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "delete directory recursively",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboards",
- ref: "main",
- comment: "Delete dashboards directory",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- dirContent := pgh.NewMockRepositoryContent(t)
- dirContent.EXPECT().IsDirectory().Return(true)
-
- // Directory contents
- file1Content := pgh.NewMockRepositoryContent(t)
- file1Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard1.json")
- file1Content.EXPECT().IsDirectory().Return(false)
- file1Content.EXPECT().GetSHA().Return("file1-sha")
-
- file2Content := pgh.NewMockRepositoryContent(t)
- file2Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard2.json")
- file2Content.EXPECT().IsDirectory().Return(false)
- file2Content.EXPECT().GetSHA().Return("file2-sha")
-
- subDirContent := pgh.NewMockRepositoryContent(t)
- subDirContent.EXPECT().GetPath().Return("grafana/dashboards/subfolder")
- subDirContent.EXPECT().IsDirectory().Return(true)
-
- // Subfolder contents
- subFile1Content := pgh.NewMockRepositoryContent(t)
- subFile1Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard.json")
- subFile1Content.EXPECT().IsDirectory().Return(false)
- subFile1Content.EXPECT().GetSHA().Return("subfile-sha")
-
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
-
- // Get main directory
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards", "main").
- Return(dirContent, []pgh.RepositoryContent{file1Content, file2Content, subDirContent}, nil)
-
- // Get subfolder contents
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder", "main").
- Return(subDirContent, []pgh.RepositoryContent{subFile1Content}, nil)
-
- // Delete files in reverse order (depth-first)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard.json", "main",
- "Delete dashboards directory", "subfile-sha").Return(nil)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard2.json", "main",
- "Delete dashboards directory", "file2-sha").Return(nil)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard1.json", "main",
- "Delete dashboards directory", "file1-sha").Return(nil)
- },
- expectedError: nil,
- },
- {
- name: "delete directory recursively fails in the middle",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboards",
- ref: "main",
- comment: "Delete dashboards directory",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- dirContent := pgh.NewMockRepositoryContent(t)
- dirContent.EXPECT().IsDirectory().Return(true)
-
- // Directory contents
- file1Content := pgh.NewMockRepositoryContent(t)
- file1Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard1.json")
- file1Content.EXPECT().IsDirectory().Return(false)
- file1Content.EXPECT().GetSHA().Return("file1-sha")
-
- file2Content := pgh.NewMockRepositoryContent(t)
- file2Content.EXPECT().GetPath().Return("grafana/dashboards/dashboard2.json")
- file2Content.EXPECT().IsDirectory().Return(false)
- file2Content.EXPECT().GetSHA().Return("file2-sha")
-
- subDirContent := pgh.NewMockRepositoryContent(t)
- subDirContent.EXPECT().IsDirectory().Return(true)
- subDirContent.EXPECT().GetPath().Return("grafana/dashboards/subfolder")
-
- // Subfolder contents
- subFile1Content := pgh.NewMockRepositoryContent(t)
- subFile1Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard.json")
- subFile1Content.EXPECT().IsDirectory().Return(false)
- subFile1Content.EXPECT().GetSHA().Return("subfile-sha")
-
- subFile2Content := pgh.NewMockRepositoryContent(t)
- subFile2Content.EXPECT().GetPath().Return("grafana/dashboards/subfolder/subdashboard2.json")
- subFile2Content.EXPECT().IsDirectory().Return(false)
- subFile2Content.EXPECT().GetSHA().Return("subfile2-sha")
-
- subFile3Content := pgh.NewMockRepositoryContent(t)
-
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
-
- // Get main directory
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards", "main").
- Return(dirContent, []pgh.RepositoryContent{file1Content, file2Content, subDirContent}, nil)
-
- // Get subfolder contents
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder", "main").
- Return(subDirContent, []pgh.RepositoryContent{subFile1Content, subFile2Content, subFile3Content}, nil)
-
- // Delete first file successfully
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard1.json", "main",
- "Delete dashboards directory", "file1-sha").Return(nil)
-
- // Second file deletion fails
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/dashboard2.json", "main",
- "Delete dashboards directory", "file2-sha").Return(nil)
-
- // Delete subfolder files
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard.json", "main",
- "Delete dashboards directory", "subfile-sha").Return(nil)
-
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboards/subfolder/subdashboard2.json", "main",
- "Delete dashboards directory", "subfile2-sha").Return(errors.New("permission denied"))
- },
- expectedError: errors.New("delete directory recursively: delete file: permission denied"),
- },
- {
- name: "file not found",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "nonexistent.json",
- ref: "main",
- comment: "Delete nonexistent file",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/nonexistent.json", "main").
- Return(nil, nil, pgh.ErrResourceNotFound)
- },
- expectedError: ErrFileNotFound,
- },
- {
- name: "branch does not exist and creation fails",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "feature",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "feature").Return(false, nil)
- mockClient.EXPECT().CreateBranch(mock.Anything, "grafana", "grafana", "main", "feature").
- Return(errors.New("failed to create branch"))
- },
- expectedError: errors.New("create branch: failed to create branch"),
- },
- {
- name: "error checking if branch exists",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").
- Return(false, errors.New("API error"))
- },
- expectedError: errors.New("check branch exists: API error"),
- },
- {
- name: "error getting file content",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(nil, nil, errors.New("API rate limit exceeded"))
- },
- expectedError: fmt.Errorf("find file to delete: %w", errors.New("API rate limit exceeded")),
- },
- {
- name: "error deleting file",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- comment: "Delete dashboard",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- fileContent := pgh.NewMockRepositoryContent(t)
- fileContent.EXPECT().IsDirectory().Return(false)
- fileContent.EXPECT().GetSHA().Return("abc123")
-
- mockClient.EXPECT().BranchExists(mock.Anything, "grafana", "grafana", "main").Return(true, nil)
- mockClient.EXPECT().GetContents(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(fileContent, nil, nil)
- mockClient.EXPECT().DeleteFile(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main",
- "Delete dashboard", "abc123").Return(errors.New("delete failed"))
- },
- expectedError: errors.New("delete failed"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Delete method
- err := repo.Delete(context.Background(), tt.path, tt.ref, tt.comment)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_History(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- path string
- ref string
- mockSetup func(t *testing.T, mockClient *pgh.MockClient)
- expected []provisioning.HistoryItem
- expectedError error
- }{
- {
- name: "successful history retrieval",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- commits := []pgh.Commit{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Author: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- Committer: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
- },
- {
- Ref: "def456",
- Message: "Initial commit",
- Author: &pgh.CommitAuthor{
- Name: "Jane Smith",
- Username: "janesmith",
- AvatarURL: "https://example.com/avatar2.png",
- },
- Committer: &pgh.CommitAuthor{
- Name: "Bob Johnson",
- Username: "bjohnson",
- AvatarURL: "https://example.com/avatar3.png",
- },
- CreatedAt: time.Date(2022, 12, 31, 10, 0, 0, 0, time.UTC),
- },
- }
-
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "dashboard.json", "main").
- Return(commits, nil)
- },
- expected: []provisioning.HistoryItem{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Authors: []provisioning.Author{
- {
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
- },
- {
- Ref: "def456",
- Message: "Initial commit",
- Authors: []provisioning.Author{
- {
- Name: "Jane Smith",
- Username: "janesmith",
- AvatarURL: "https://example.com/avatar2.png",
- },
- {
- Name: "Bob Johnson",
- Username: "bjohnson",
- AvatarURL: "https://example.com/avatar3.png",
- },
- },
- CreatedAt: time.Date(2022, 12, 31, 10, 0, 0, 0, time.UTC).UnixMilli(),
- },
- },
- },
- {
- name: "committer same as author",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- commits := []pgh.Commit{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Author: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- Committer: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
- },
- }
-
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(commits, nil)
- },
- expected: []provisioning.HistoryItem{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Authors: []provisioning.Author{
- {
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
- },
- },
- },
- {
- name: "file not found",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "nonexistent.json",
- ref: "main",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/nonexistent.json", "main").
- Return(nil, pgh.ErrResourceNotFound)
- },
- expectedError: ErrFileNotFound,
- },
- {
- name: "prefixed path",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "custom/prefix",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- commits := []pgh.Commit{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Author: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
- },
- }
-
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "custom/prefix/dashboard.json", "main").
- Return(commits, nil)
- },
- expected: []provisioning.HistoryItem{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Authors: []provisioning.Author{
- {
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
- },
- },
- },
- {
- name: "other error",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "main",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(nil, errors.New("api error"))
- },
- expectedError: errors.New("get commits: api error"),
- },
- {
- name: "use default branch when ref is empty",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Path: "grafana",
- Branch: "main",
- },
- },
- },
- path: "dashboard.json",
- ref: "",
- mockSetup: func(t *testing.T, mockClient *pgh.MockClient) {
- commits := []pgh.Commit{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Author: &pgh.CommitAuthor{
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
- },
- }
-
- mockClient.EXPECT().Commits(mock.Anything, "grafana", "grafana", "grafana/dashboard.json", "main").
- Return(commits, nil)
- },
- expected: []provisioning.HistoryItem{
- {
- Ref: "abc123",
- Message: "Update dashboard",
- Authors: []provisioning.Author{
- {
- Name: "John Doe",
- Username: "johndoe",
- AvatarURL: "https://example.com/avatar1.png",
- },
- },
- CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli(),
- },
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create a mock GitHub client
- mockClient := pgh.NewMockClient(t)
-
- // Set up the mock expectations
- if tt.mockSetup != nil {
- tt.mockSetup(t, mockClient)
- }
-
- // Create a GitHub repository with the test config and mock client
- repo := &githubRepository{
- config: tt.config,
- gh: mockClient,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the History method
- history, err := repo.History(context.Background(), tt.path, tt.ref)
-
- // Check the error
- if tt.expectedError != nil {
- require.Error(t, err)
- var statusErr *apierrors.StatusError
- if errors.As(tt.expectedError, &statusErr) {
- var actualStatusErr *apierrors.StatusError
- require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
- require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
- require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
- } else {
- require.Equal(t, tt.expectedError.Error(), err.Error())
- }
- } else {
- require.NoError(t, err)
- require.Equal(t, tt.expected, history)
- }
-
- // Verify all mock expectations were met
- mockClient.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_LatestRef(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(mock *pgh.MockClient)
- expectedRef string
- expectedError error
- }{
- {
- name: "successful retrieval of latest ref",
- setupMock: func(m *pgh.MockClient) {
- m.On("GetBranch", mock.Anything, "grafana", "grafana", "main").
- Return(pgh.Branch{Sha: "abc123"}, nil)
- },
- expectedRef: "abc123",
- expectedError: nil,
- },
- {
- name: "error getting branch",
- setupMock: func(m *pgh.MockClient) {
- m.On("GetBranch", mock.Anything, "grafana", "grafana", "main").
- Return(pgh.Branch{}, fmt.Errorf("branch not found"))
- },
- expectedRef: "",
- expectedError: fmt.Errorf("get branch: branch not found"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
- tt.setupMock(mockGH)
-
- // Create repository with mock
- repo := &githubRepository{
- gh: mockGH,
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- },
- },
- },
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the LatestRef method
- ref, err := repo.LatestRef(context.Background())
-
- // Check results
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- require.Equal(t, tt.expectedRef, ref)
- }
-
- // Verify all mock expectations were met
- mockGH.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_CompareFiles(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(m *pgh.MockClient)
- base string
- ref string
- expectedFiles []VersionedFileChange
- expectedError error
- shouldGetLatest bool
- }{
- {
- name: "successfully compare files",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/test.json")
- commitFile1.On("GetStatus").Return("added")
-
- commitFile2 := pgh.NewMockCommitFile(t)
- commitFile2.On("GetFilename").Return("dashboards/modified.json")
- commitFile2.On("GetStatus").Return("modified")
-
- commitFile3 := pgh.NewMockCommitFile(t)
- commitFile3.On("GetFilename").Return("dashboards/renamed.json")
- commitFile3.On("GetStatus").Return("renamed")
- commitFile3.On("GetPreviousFilename").Return("dashboards/old.json")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- commitFile2,
- commitFile3,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "test.json",
- Ref: "def456",
- Action: FileActionCreated,
- },
- {
- Path: "modified.json",
- Ref: "def456",
- Action: FileActionUpdated,
- },
- {
- Path: "renamed.json",
- Ref: "def456",
- Action: FileActionRenamed,
- PreviousPath: "old.json",
- },
- },
- expectedError: nil,
- },
- {
- name: "error comparing commits",
- setupMock: func(m *pgh.MockClient) {
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return(nil, fmt.Errorf("failed to compare commits"))
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: nil,
- expectedError: fmt.Errorf("compare commits: failed to compare commits"),
- },
- {
- name: "file outside configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/path.json")
- commitFile1.On("GetStatus").Return("added")
-
- commitFile2 := pgh.NewMockCommitFile(t)
- commitFile2.On("GetFilename").Return("dashboards/valid.json")
- commitFile2.On("GetStatus").Return("added")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- commitFile2,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "valid.json",
- Ref: "def456",
- Action: FileActionCreated,
- },
- },
- expectedError: nil,
- },
- {
- name: "modified file outside configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/modified.json")
- commitFile1.On("GetStatus").Return("modified")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "copied file status",
- setupMock: func(m *pgh.MockClient) {
- // File inside configured path
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/copied.json")
- commitFile1.On("GetStatus").Return("copied")
-
- // File outside configured path
- commitFile2 := pgh.NewMockCommitFile(t)
- commitFile2.On("GetFilename").Return("../outside/copied.json")
- commitFile2.On("GetStatus").Return("copied")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- commitFile2,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "copied.json",
- Ref: "def456",
- Action: FileActionCreated,
- },
- },
- expectedError: nil,
- },
- {
- name: "removed file status - inside path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/removed.json")
- commitFile1.On("GetStatus").Return("removed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "removed.json",
- PreviousPath: "removed.json",
- Ref: "def456",
- PreviousRef: "abc123",
- Action: FileActionDeleted,
- },
- },
- expectedError: nil,
- },
- {
- name: "renamed file status - both paths outside configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/renamed.json")
- commitFile1.On("GetPreviousFilename").Return("../outside/original.json")
- commitFile1.On("GetStatus").Return("renamed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "renamed file status - both paths inside configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/renamed.json")
- commitFile1.On("GetPreviousFilename").Return("dashboards/original.json")
- commitFile1.On("GetStatus").Return("renamed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "renamed.json",
- PreviousPath: "original.json",
- Ref: "def456",
- PreviousRef: "abc123",
- Action: FileActionRenamed,
- },
- },
- expectedError: nil,
- },
- {
- name: "renamed file status - moving out of configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/renamed.json")
- commitFile1.On("GetPreviousFilename").Return("dashboards/original.json")
- commitFile1.On("GetStatus").Return("renamed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "original.json",
- Ref: "abc123",
- Action: FileActionDeleted,
- },
- },
- expectedError: nil,
- },
- {
- name: "renamed file status - moving into configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/renamed.json")
- commitFile1.On("GetPreviousFilename").Return("../outside/original.json")
- commitFile1.On("GetStatus").Return("renamed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{
- {
- Path: "renamed.json",
- Ref: "def456",
- Action: FileActionCreated,
- },
- },
- expectedError: nil,
- },
- {
- name: "removed file status - outside path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/removed.json")
- commitFile1.On("GetStatus").Return("removed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "changed file outside configured path",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("../outside/changed.json")
- commitFile1.On("GetStatus").Return("changed")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "get latest ref when ref is empty",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/test.json")
- commitFile1.On("GetStatus").Return("added")
-
- m.On("GetBranch", mock.Anything, "grafana", "grafana", "main").
- Return(pgh.Branch{Sha: "latest123"}, nil)
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "latest123").
- Return([]pgh.CommitFile{commitFile1}, nil)
- },
- base: "abc123",
- ref: "",
- shouldGetLatest: true,
- expectedFiles: []VersionedFileChange{
- {
- Path: "test.json",
- Ref: "latest123",
- Action: FileActionCreated,
- },
- },
- expectedError: nil,
- },
- {
- name: "unchanged file status",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetStatus").Return("unchanged")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "unknown file status",
- setupMock: func(m *pgh.MockClient) {
- commitFile1 := pgh.NewMockCommitFile(t)
- commitFile1.On("GetFilename").Return("dashboards/unknown.json")
- commitFile1.On("GetStatus").Return("unknown_status")
-
- m.On("CompareCommits", mock.Anything, "grafana", "grafana", "abc123", "def456").
- Return([]pgh.CommitFile{
- commitFile1,
- }, nil)
- },
- base: "abc123",
- ref: "def456",
- expectedFiles: []VersionedFileChange{},
- expectedError: nil,
- },
- {
- name: "error getting latest ref",
- setupMock: func(m *pgh.MockClient) {
- m.On("GetBranch", mock.Anything, "grafana", "grafana", "main").
- Return(pgh.Branch{}, fmt.Errorf("branch not found"))
- },
- base: "abc123",
- ref: "",
- shouldGetLatest: true,
- expectedFiles: nil,
- expectedError: fmt.Errorf("get latest ref: get branch: branch not found"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
- tt.setupMock(mockGH)
-
- // Create repository with mock
- repo := &githubRepository{
- gh: mockGH,
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- Path: "dashboards",
- },
- },
- },
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the CompareFiles method
- files, err := repo.CompareFiles(context.Background(), tt.base, tt.ref)
-
- // Check results
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- require.Equal(t, len(tt.expectedFiles), len(files))
-
- for i, expectedFile := range tt.expectedFiles {
- require.Equal(t, expectedFile.Path, files[i].Path)
- require.Equal(t, expectedFile.Ref, files[i].Ref)
- require.Equal(t, expectedFile.Action, files[i].Action)
- require.Equal(t, expectedFile.PreviousPath, files[i].PreviousPath)
- }
- }
-
- // Verify all mock expectations were met
- mockGH.AssertExpectations(t)
- })
- }
-}
-
-func TestGitHubRepository_ResourceURLs(t *testing.T) {
- tests := []struct {
- name string
- file *FileInfo
- config *provisioning.Repository
- expectedURLs *provisioning.ResourceURLs
- expectedError error
- }{
- {
- name: "file with ref",
- file: &FileInfo{
- Path: "dashboards/test.json",
- Ref: "feature-branch",
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- },
- },
- },
- expectedURLs: &provisioning.ResourceURLs{
- RepositoryURL: "https://github.com/grafana/grafana",
- SourceURL: "https://github.com/grafana/grafana/blob/feature-branch/dashboards/test.json",
- CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch",
- NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana",
- },
- expectedError: nil,
- },
- {
- name: "file without ref uses default branch",
- file: &FileInfo{
- Path: "dashboards/test.json",
- Ref: "",
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- },
- },
- },
- expectedURLs: &provisioning.ResourceURLs{
- RepositoryURL: "https://github.com/grafana/grafana",
- SourceURL: "https://github.com/grafana/grafana/blob/main/dashboards/test.json",
- },
- expectedError: nil,
- },
- {
- name: "file with ref same as branch",
- file: &FileInfo{
- Path: "dashboards/test.json",
- Ref: "main",
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- },
- },
- },
- expectedURLs: &provisioning.ResourceURLs{
- RepositoryURL: "https://github.com/grafana/grafana",
- SourceURL: "https://github.com/grafana/grafana/blob/main/dashboards/test.json",
- },
- expectedError: nil,
- },
- {
- name: "empty path returns nil",
- file: &FileInfo{
- Path: "",
- Ref: "feature-branch",
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/grafana",
- Branch: "main",
- },
- },
- },
- expectedURLs: nil,
- expectedError: nil,
- },
- {
- name: "nil github config returns nil",
- file: &FileInfo{
- Path: "dashboards/test.json",
- Ref: "feature-branch",
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: nil,
- },
- },
- expectedURLs: nil,
- expectedError: nil,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create repository
- repo := &githubRepository{
- config: tt.config,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the ResourceURLs method
- urls, err := repo.ResourceURLs(context.Background(), tt.file)
-
- // Check results
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- require.Equal(t, tt.expectedURLs, urls)
- }
- })
- }
-}
-
-func TestGitHubRepository_Clone(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(m *MockCloneFn)
- config *provisioning.Repository
- expectedError error
- }{
- {
- name: "successfully clone repository",
- setupMock: func(m *MockCloneFn) {
- m.On("Execute", mock.Anything, CloneOptions{
- CreateIfNotExists: true,
- PushOnWrites: true,
- MaxSize: 1024 * 1024 * 10, // 10MB
- Timeout: 10 * time.Second,
- Progress: io.Discard,
- BeforeFn: nil,
- }).Return(nil, nil)
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- },
- },
- },
- expectedError: nil,
- },
- {
- name: "error cloning repository",
- setupMock: func(m *MockCloneFn) {
- m.On("Execute", mock.Anything, CloneOptions{
- CreateIfNotExists: true,
- PushOnWrites: true,
- MaxSize: 1024 * 1024 * 10, // 10MB
- Timeout: 10 * time.Second,
- Progress: io.Discard,
- BeforeFn: nil,
- }).Return(nil, fmt.Errorf("failed to clone repository"))
- },
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- },
- },
- },
- expectedError: fmt.Errorf("failed to clone repository"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- mockCloneFn := NewMockCloneFn(t)
-
- tt.setupMock(mockCloneFn)
-
- // Create repository with mock
- repo := &githubRepository{
- cloneFn: mockCloneFn.Execute,
- config: tt.config,
- owner: "grafana",
- repo: "grafana",
- }
-
- // Call the Clone method with a placeholder directory path
- _, err := repo.Clone(context.Background(), CloneOptions{
- CreateIfNotExists: true,
- PushOnWrites: true,
- MaxSize: 1024 * 1024 * 10, // 10MB
- Timeout: 10 * time.Second,
- Progress: io.Discard,
- BeforeFn: nil,
- })
-
- // Check results
- if tt.expectedError != nil {
- require.Error(t, err)
- require.Equal(t, tt.expectedError.Error(), err.Error())
- } else {
- require.NoError(t, err)
- }
-
- // Verify all mock expectations were met
- mockCloneFn.AssertExpectations(t)
- })
- }
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/progress.go b/pkg/registry/apis/provisioning/repository/go-git/progress.go
deleted file mode 100644
index 29e840499ad..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/progress.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package gogit
-
-import (
- "bufio"
- "bytes"
- "io"
-)
-
-func Progress(lines func(line string), final string) io.WriteCloser {
- reader, writer := io.Pipe()
- scanner := bufio.NewScanner(reader)
- scanner.Split(scanLines)
- go func() {
- for scanner.Scan() {
- line := scanner.Text()
- if line != "" {
- lines(line)
- }
- }
- lines(final)
- }()
- return writer
-}
-
-// Copied from bufio.ScanLines and modifed to accept standalone \r as input
-func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
- if atEOF && len(data) == 0 {
- return 0, nil, nil
- }
- if i := bytes.IndexByte(data, '\r'); i >= 0 {
- // We have a full newline-terminated line.
- return i + 1, data[0:i], nil
- }
-
- // Support standalone newlines also
- if i := bytes.IndexByte(data, '\n'); i >= 0 {
- // We have a full newline-terminated line.
- return i + 1, data[0:i], nil
- }
-
- // If we're at EOF, we have a final, non-terminated line. Return it.
- if atEOF {
- return len(data), data, nil
- }
- // Request more data.
- return 0, nil, nil
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/progress_test.go b/pkg/registry/apis/provisioning/repository/go-git/progress_test.go
deleted file mode 100644
index d52168c8cd5..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/progress_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package gogit
-
-import (
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestProgressParsing(t *testing.T) {
- tests := []struct {
- name string
- input string
- expect []string
- }{
- {
- name: "no breaks",
- input: "some text",
- expect: []string{"some text"},
- },
- {
- name: "with cr",
- input: "hello\rworld",
- expect: []string{"hello", "world"},
- },
- {
- name: "with nl",
- input: "hello\nworld",
- expect: []string{"hello", "world"},
- },
- {
- name: "with cr+nl",
- input: "hello\r\nworld",
- expect: []string{"hello", "world"},
- },
- }
- for _, tt := range tests {
- lastLine := "***LAST*LINE***"
- t.Run(tt.name, func(t *testing.T) {
- lines := []string{}
- writer := Progress(func(line string) {
- lines = append(lines, line)
- }, lastLine)
- _, _ = writer.Write([]byte(tt.input))
- err := writer.Close()
- require.NoError(t, err)
-
- assert.EventuallyWithT(t, func(c *assert.CollectT) {
- assert.NotEmpty(c, lines)
- assert.Equal(c, lastLine, lines[len(lines)-1])
-
- // Compare the results
- require.Equal(c, tt.expect, lines[0:len(lines)-1])
- }, time.Millisecond*100, time.Microsecond*50)
- })
- }
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go b/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go
deleted file mode 100644
index b00f395beac..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/repository_mock.go
+++ /dev/null
@@ -1,84 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package gogit
-
-import (
- context "context"
-
- git "github.com/go-git/go-git/v5"
- mock "github.com/stretchr/testify/mock"
-)
-
-// MockRepository is an autogenerated mock type for the Repository type
-type MockRepository struct {
- mock.Mock
-}
-
-type MockRepository_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockRepository) EXPECT() *MockRepository_Expecter {
- return &MockRepository_Expecter{mock: &_m.Mock}
-}
-
-// PushContext provides a mock function with given fields: ctx, o
-func (_m *MockRepository) PushContext(ctx context.Context, o *git.PushOptions) error {
- ret := _m.Called(ctx, o)
-
- if len(ret) == 0 {
- panic("no return value specified for PushContext")
- }
-
- var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, *git.PushOptions) error); ok {
- r0 = rf(ctx, o)
- } else {
- r0 = ret.Error(0)
- }
-
- return r0
-}
-
-// MockRepository_PushContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushContext'
-type MockRepository_PushContext_Call struct {
- *mock.Call
-}
-
-// PushContext is a helper method to define mock.On call
-// - ctx context.Context
-// - o *git.PushOptions
-func (_e *MockRepository_Expecter) PushContext(ctx interface{}, o interface{}) *MockRepository_PushContext_Call {
- return &MockRepository_PushContext_Call{Call: _e.mock.On("PushContext", ctx, o)}
-}
-
-func (_c *MockRepository_PushContext_Call) Run(run func(ctx context.Context, o *git.PushOptions)) *MockRepository_PushContext_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(*git.PushOptions))
- })
- return _c
-}
-
-func (_c *MockRepository_PushContext_Call) Return(_a0 error) *MockRepository_PushContext_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockRepository_PushContext_Call) RunAndReturn(run func(context.Context, *git.PushOptions) error) *MockRepository_PushContext_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockRepository creates a new instance of MockRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockRepository(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockRepository {
- mock := &MockRepository{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/transport.go b/pkg/registry/apis/provisioning/repository/go-git/transport.go
deleted file mode 100644
index 03d9feeb798..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/transport.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package gogit
-
-import (
- "fmt"
- "io"
- "net/http"
- "sync/atomic"
-
- "github.com/grafana/grafana/pkg/util/httpclient"
-)
-
-var errBytesLimitExceeded = fmt.Errorf("bytes limit exceeded")
-
-// ByteLimitedTransport wraps http.RoundTripper to enforce a max byte limit
-type ByteLimitedTransport struct {
- Transport http.RoundTripper
- Limit int64
- Bytes int64
-}
-
-// NewByteLimitedTransport creates a new ByteLimitedTransport with the specified transport and byte limit.
-// If transport is nil, a new http.Transport modeled after http.DefaultTransport will be used.
-func NewByteLimitedTransport(transport http.RoundTripper, limit int64) *ByteLimitedTransport {
- if transport == nil {
- transport = httpclient.NewHTTPTransport()
- }
- return &ByteLimitedTransport{
- Transport: transport,
- Limit: limit,
- Bytes: 0,
- }
-}
-
-// RoundTrip tracks downloaded bytes and aborts if limit is exceeded
-func (b *ByteLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
- resp, err := b.Transport.RoundTrip(req)
- if err != nil {
- return nil, err
- }
-
- // Wrap response body to track bytes read
- resp.Body = &byteLimitedReader{
- reader: resp.Body,
- limit: b.Limit,
- bytes: &b.Bytes,
- }
-
- return resp, nil
-}
-
-// byteLimitedReader tracks and enforces a download limit
-type byteLimitedReader struct {
- reader io.ReadCloser
- limit int64
- bytes *int64
-}
-
-func (r *byteLimitedReader) Read(p []byte) (int, error) {
- n, err := r.reader.Read(p)
- if err != nil {
- return n, err
- }
-
- if atomic.AddInt64(r.bytes, int64(n)) > r.limit {
- return 0, errBytesLimitExceeded
- }
-
- return n, nil
-}
-
-func (r *byteLimitedReader) Close() error {
- return r.reader.Close()
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/transport_test.go b/pkg/registry/apis/provisioning/repository/go-git/transport_test.go
deleted file mode 100644
index 292046cf42f..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/transport_test.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package gogit
-
-import (
- "bytes"
- "errors"
- "io"
- "net/http"
- "sync/atomic"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-type mockTransport struct {
- response *http.Response
- err error
-}
-
-func (m *mockTransport) RoundTrip(*http.Request) (*http.Response, error) {
- return m.response, m.err
-}
-
-func TestNewByteLimitedTransport(t *testing.T) {
- tests := []struct {
- name string
- transport http.RoundTripper
- limit int64
- }{
- {
- name: "with custom transport",
- transport: &mockTransport{},
- limit: 1000,
- },
- {
- name: "with nil transport",
- transport: nil,
- limit: 1000,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- blt := NewByteLimitedTransport(tt.transport, tt.limit)
- assert.NotNil(t, blt)
- assert.Equal(t, tt.limit, blt.Limit)
- assert.Equal(t, int64(0), blt.Bytes)
-
- if tt.transport == nil {
- assert.NotNil(t, blt.Transport)
- assert.NotEqual(t, http.DefaultTransport, blt.Transport)
- } else {
- assert.Equal(t, tt.transport, blt.Transport)
- }
- })
- }
-}
-
-func TestByteLimitedTransport_RoundTrip(t *testing.T) {
- tests := []struct {
- name string
- responseBody string
- limit int64
- expectedError error
- }{
- {
- name: "under limit",
- responseBody: "small response",
- limit: 100,
- expectedError: nil,
- },
- {
- name: "exceeds limit",
- responseBody: "this response will exceed the byte limit",
- limit: 10,
- expectedError: errBytesLimitExceeded,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- mockResp := &http.Response{
- Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
- }
- mockTransport := &mockTransport{response: mockResp}
-
- blt := NewByteLimitedTransport(mockTransport, tt.limit)
- resp, err := blt.RoundTrip(&http.Request{})
- require.NoError(t, err)
- defer func() {
- closeErr := resp.Body.Close()
- assert.NoError(t, closeErr, "failed to close response body")
- }()
-
- data, err := io.ReadAll(resp.Body)
- if tt.expectedError != nil {
- assert.True(t, errors.Is(err, tt.expectedError), "expected error %v, got %v", tt.expectedError, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tt.responseBody, string(data))
- }
- })
- }
-}
-
-func TestByteLimitedReader_Close(t *testing.T) {
- mockBody := io.NopCloser(bytes.NewBufferString("test"))
- var byteCount int64
- reader := &byteLimitedReader{
- reader: mockBody,
- limit: 100,
- bytes: &byteCount,
- }
-
- err := reader.Close()
- assert.NoError(t, err)
-}
-
-func TestByteLimitedReader_AtomicCounting(t *testing.T) {
- var byteCount int64
- reader := &byteLimitedReader{
- reader: io.NopCloser(bytes.NewBufferString("test data")),
- limit: 5,
- bytes: &byteCount,
- }
-
- // First read should succeed
- buf := make([]byte, 4)
- n, err := reader.Read(buf)
- assert.NoError(t, err)
- assert.Equal(t, 4, n)
-
- // Second read should fail due to limit
- n, err = reader.Read(buf)
- assert.True(t, errors.Is(err, errBytesLimitExceeded), "expected error %v, got %v", errBytesLimitExceeded, err)
- assert.Equal(t, 0, n)
-
- // Verify atomic counter
- assert.Greater(t, atomic.LoadInt64(&byteCount), int64(5))
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go b/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go
deleted file mode 100644
index e59506eb1ac..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/worktree_mock.go
+++ /dev/null
@@ -1,261 +0,0 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
-
-package gogit
-
-import (
- billy "github.com/go-git/go-billy/v5"
- git "github.com/go-git/go-git/v5"
-
- mock "github.com/stretchr/testify/mock"
-
- plumbing "github.com/go-git/go-git/v5/plumbing"
-)
-
-// MockWorktree is an autogenerated mock type for the Worktree type
-type MockWorktree struct {
- mock.Mock
-}
-
-type MockWorktree_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockWorktree) EXPECT() *MockWorktree_Expecter {
- return &MockWorktree_Expecter{mock: &_m.Mock}
-}
-
-// Add provides a mock function with given fields: path
-func (_m *MockWorktree) Add(path string) (plumbing.Hash, error) {
- ret := _m.Called(path)
-
- if len(ret) == 0 {
- panic("no return value specified for Add")
- }
-
- var r0 plumbing.Hash
- var r1 error
- if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok {
- return rf(path)
- }
- if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok {
- r0 = rf(path)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(plumbing.Hash)
- }
- }
-
- if rf, ok := ret.Get(1).(func(string) error); ok {
- r1 = rf(path)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockWorktree_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add'
-type MockWorktree_Add_Call struct {
- *mock.Call
-}
-
-// Add is a helper method to define mock.On call
-// - path string
-func (_e *MockWorktree_Expecter) Add(path interface{}) *MockWorktree_Add_Call {
- return &MockWorktree_Add_Call{Call: _e.mock.On("Add", path)}
-}
-
-func (_c *MockWorktree_Add_Call) Run(run func(path string)) *MockWorktree_Add_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(string))
- })
- return _c
-}
-
-func (_c *MockWorktree_Add_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Add_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockWorktree_Add_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Add_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// Commit provides a mock function with given fields: message, opts
-func (_m *MockWorktree) Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error) {
- ret := _m.Called(message, opts)
-
- if len(ret) == 0 {
- panic("no return value specified for Commit")
- }
-
- var r0 plumbing.Hash
- var r1 error
- if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) (plumbing.Hash, error)); ok {
- return rf(message, opts)
- }
- if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) plumbing.Hash); ok {
- r0 = rf(message, opts)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(plumbing.Hash)
- }
- }
-
- if rf, ok := ret.Get(1).(func(string, *git.CommitOptions) error); ok {
- r1 = rf(message, opts)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockWorktree_Commit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commit'
-type MockWorktree_Commit_Call struct {
- *mock.Call
-}
-
-// Commit is a helper method to define mock.On call
-// - message string
-// - opts *git.CommitOptions
-func (_e *MockWorktree_Expecter) Commit(message interface{}, opts interface{}) *MockWorktree_Commit_Call {
- return &MockWorktree_Commit_Call{Call: _e.mock.On("Commit", message, opts)}
-}
-
-func (_c *MockWorktree_Commit_Call) Run(run func(message string, opts *git.CommitOptions)) *MockWorktree_Commit_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(string), args[1].(*git.CommitOptions))
- })
- return _c
-}
-
-func (_c *MockWorktree_Commit_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Commit_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockWorktree_Commit_Call) RunAndReturn(run func(string, *git.CommitOptions) (plumbing.Hash, error)) *MockWorktree_Commit_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// Filesystem provides a mock function with no fields
-func (_m *MockWorktree) Filesystem() billy.Filesystem {
- ret := _m.Called()
-
- if len(ret) == 0 {
- panic("no return value specified for Filesystem")
- }
-
- var r0 billy.Filesystem
- if rf, ok := ret.Get(0).(func() billy.Filesystem); ok {
- r0 = rf()
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(billy.Filesystem)
- }
- }
-
- return r0
-}
-
-// MockWorktree_Filesystem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Filesystem'
-type MockWorktree_Filesystem_Call struct {
- *mock.Call
-}
-
-// Filesystem is a helper method to define mock.On call
-func (_e *MockWorktree_Expecter) Filesystem() *MockWorktree_Filesystem_Call {
- return &MockWorktree_Filesystem_Call{Call: _e.mock.On("Filesystem")}
-}
-
-func (_c *MockWorktree_Filesystem_Call) Run(run func()) *MockWorktree_Filesystem_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run()
- })
- return _c
-}
-
-func (_c *MockWorktree_Filesystem_Call) Return(_a0 billy.Filesystem) *MockWorktree_Filesystem_Call {
- _c.Call.Return(_a0)
- return _c
-}
-
-func (_c *MockWorktree_Filesystem_Call) RunAndReturn(run func() billy.Filesystem) *MockWorktree_Filesystem_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// Remove provides a mock function with given fields: path
-func (_m *MockWorktree) Remove(path string) (plumbing.Hash, error) {
- ret := _m.Called(path)
-
- if len(ret) == 0 {
- panic("no return value specified for Remove")
- }
-
- var r0 plumbing.Hash
- var r1 error
- if rf, ok := ret.Get(0).(func(string) (plumbing.Hash, error)); ok {
- return rf(path)
- }
- if rf, ok := ret.Get(0).(func(string) plumbing.Hash); ok {
- r0 = rf(path)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(plumbing.Hash)
- }
- }
-
- if rf, ok := ret.Get(1).(func(string) error); ok {
- r1 = rf(path)
- } else {
- r1 = ret.Error(1)
- }
-
- return r0, r1
-}
-
-// MockWorktree_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
-type MockWorktree_Remove_Call struct {
- *mock.Call
-}
-
-// Remove is a helper method to define mock.On call
-// - path string
-func (_e *MockWorktree_Expecter) Remove(path interface{}) *MockWorktree_Remove_Call {
- return &MockWorktree_Remove_Call{Call: _e.mock.On("Remove", path)}
-}
-
-func (_c *MockWorktree_Remove_Call) Run(run func(path string)) *MockWorktree_Remove_Call {
- _c.Call.Run(func(args mock.Arguments) {
- run(args[0].(string))
- })
- return _c
-}
-
-func (_c *MockWorktree_Remove_Call) Return(_a0 plumbing.Hash, _a1 error) *MockWorktree_Remove_Call {
- _c.Call.Return(_a0, _a1)
- return _c
-}
-
-func (_c *MockWorktree_Remove_Call) RunAndReturn(run func(string) (plumbing.Hash, error)) *MockWorktree_Remove_Call {
- _c.Call.Return(run)
- return _c
-}
-
-// NewMockWorktree creates a new instance of MockWorktree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockWorktree(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockWorktree {
- mock := &MockWorktree{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
deleted file mode 100644
index e6954d27b81..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
+++ /dev/null
@@ -1,468 +0,0 @@
-package gogit
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "net/http"
- "os"
- "strings"
- "time"
-
- "github.com/go-git/go-billy/v5"
- "github.com/go-git/go-billy/v5/util"
- "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/go-git/go-git/v5/plumbing/transport/client"
- githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/util/validation/field"
-
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
- "github.com/grafana/grafana/pkg/util/httpclient"
-)
-
-const (
- // maxOperationBytes is the maximum size of a git operation in bytes (1 GB)
- maxOperationBytes = int64(1 << 30)
- maxOperationTimeout = 10 * time.Minute
-)
-
-func init() {
- // Create a size-limited writer that will cancel the context if size is exceeded
- limitedTransport := NewByteLimitedTransport(httpclient.NewHTTPTransport(), maxOperationBytes)
- httpClient := githttp.NewClient(&http.Client{
- Transport: limitedTransport,
- })
- client.InstallProtocol("https", httpClient)
- client.InstallProtocol("http", httpClient)
-}
-
-//go:generate mockery --name=Worktree --output=mocks --inpackage --filename=worktree_mock.go --with-expecter
-type Worktree interface {
- Commit(message string, opts *git.CommitOptions) (plumbing.Hash, error)
- Remove(path string) (plumbing.Hash, error)
- Add(path string) (plumbing.Hash, error)
- Filesystem() billy.Filesystem
-}
-
-type worktree struct {
- *git.Worktree
-}
-
-//go:generate mockery --name=Repository --output=mocks --inpackage --filename=repository_mock.go --with-expecter
-type Repository interface {
- PushContext(ctx context.Context, o *git.PushOptions) error
-}
-
-func (w *worktree) Filesystem() billy.Filesystem {
- return w.Worktree.Filesystem
-}
-
-var _ repository.Repository = (*GoGitRepo)(nil)
-
-type GoGitRepo struct {
- config *provisioning.Repository
- decryptedPassword string
- opts repository.CloneOptions
-
- repo Repository
- tree Worktree
- dir string // file path to worktree root (necessary? should use billy)
-}
-
-// This will create a new clone every time
-// As structured, it is valid for one context and should not be shared across multiple requests
-func Clone(
- ctx context.Context,
- root string,
- config *provisioning.Repository,
- opts repository.CloneOptions,
- secrets secrets.Service,
-) (repository.ClonedRepository, error) {
- if root == "" {
- return nil, fmt.Errorf("missing root config")
- }
-
- if config.Namespace == "" {
- return nil, fmt.Errorf("config is missing namespace")
- }
-
- if config.Name == "" {
- return nil, fmt.Errorf("config is missing name")
- }
-
- if opts.BeforeFn != nil {
- if err := opts.BeforeFn(); err != nil {
- return nil, err
- }
- }
-
- // add a timeout to the operation
- timeout := maxOperationTimeout
- if opts.Timeout > 0 {
- timeout = opts.Timeout
- }
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
- if err != nil {
- return nil, fmt.Errorf("error decrypting token: %w", err)
- }
-
- if err := os.MkdirAll(root, 0700); err != nil {
- return nil, fmt.Errorf("create root dir: %w", err)
- }
-
- dir, err := os.MkdirTemp(root, fmt.Sprintf("clone-%s-%s-", config.Namespace, config.Name))
- if err != nil {
- return nil, fmt.Errorf("create temp clone dir: %w", err)
- }
-
- progress := opts.Progress
- if progress == nil {
- progress = io.Discard
- }
-
- repo, tree, err := clone(ctx, config, opts, decrypted, dir, progress)
- if err != nil {
- if err := os.RemoveAll(dir); err != nil {
- return nil, fmt.Errorf("remove temp clone dir after clone failed: %w", err)
- }
-
- return nil, fmt.Errorf("clone: %w", err)
- }
-
- return &GoGitRepo{
- config: config,
- tree: &worktree{Worktree: tree},
- opts: opts,
- decryptedPassword: string(decrypted),
- repo: repo,
- dir: dir,
- }, nil
-}
-
-func clone(ctx context.Context, config *provisioning.Repository, opts repository.CloneOptions, decrypted []byte, dir string, progress io.Writer) (*git.Repository, *git.Worktree, error) {
- gitcfg := config.Spec.GitHub
- url := gitcfg.URL
- if !strings.HasPrefix(url, "file://") {
- url = fmt.Sprintf("%s.git", url)
- }
-
- branch := plumbing.NewBranchReferenceName(gitcfg.Branch)
- cloneOpts := &git.CloneOptions{
- ReferenceName: branch,
- Auth: &githttp.BasicAuth{
- Username: "grafana", // this can be anything except an empty string for PAT
- Password: string(decrypted), // TODO... will need to get from a service!
- },
- URL: url,
- Progress: progress,
- }
-
- repo, err := git.PlainCloneContext(ctx, dir, false, cloneOpts)
- if errors.Is(err, plumbing.ErrReferenceNotFound) && opts.CreateIfNotExists {
- cloneOpts.ReferenceName = "" // empty
- repo, err = git.PlainCloneContext(ctx, dir, false, cloneOpts)
- if err == nil {
- worktree, err := repo.Worktree()
- if err != nil {
- return nil, nil, err
- }
- err = worktree.Checkout(&git.CheckoutOptions{
- Branch: branch,
- Force: true,
- Create: true,
- })
- if err != nil {
- return nil, nil, fmt.Errorf("unable to create new branch: %w", err)
- }
- }
- } else if err != nil {
- return nil, nil, fmt.Errorf("clone error: %w", err)
- }
-
- rcfg, err := repo.Config()
- if err != nil {
- return nil, nil, fmt.Errorf("error reading repository config %w", err)
- }
-
- origin := rcfg.Remotes["origin"]
- if origin == nil {
- return nil, nil, fmt.Errorf("missing origin remote %w", err)
- }
-
- if url != origin.URLs[0] {
- return nil, nil, fmt.Errorf("unexpected remote (expected: %s, found: %s)", url, origin.URLs[0])
- }
-
- worktree, err := repo.Worktree()
- if err != nil {
- return nil, nil, fmt.Errorf("get worktree: %w", err)
- }
-
- return repo, worktree, nil
-}
-
-// After making changes to the worktree, push changes
-func (g *GoGitRepo) Push(ctx context.Context, opts repository.PushOptions) error {
- timeout := maxOperationTimeout
- if opts.Timeout > 0 {
- timeout = opts.Timeout
- }
-
- progress := opts.Progress
- if progress == nil {
- progress = io.Discard
- }
-
- if opts.BeforeFn != nil {
- if err := opts.BeforeFn(); err != nil {
- return err
- }
- }
-
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- if !g.opts.PushOnWrites {
- _, err := g.tree.Commit("exported from grafana", &git.CommitOptions{
- All: true, // Add everything that changed
- })
- if err != nil {
- // empty commit is fine -- no change
- if !errors.Is(err, git.ErrEmptyCommit) {
- return err
- }
- }
- }
-
- err := g.repo.PushContext(ctx, &git.PushOptions{
- Progress: progress,
- Force: true, // avoid fast-forward-errors
- Auth: &githttp.BasicAuth{ // reuse logic from clone?
- Username: "grafana",
- Password: g.decryptedPassword,
- },
- })
- if errors.Is(err, git.NoErrAlreadyUpToDate) {
- return nil // same as the target
- }
- return err
-}
-
-func (g *GoGitRepo) Remove(ctx context.Context) error {
- return os.RemoveAll(g.dir)
-}
-
-// Config implements repository.Repository.
-func (g *GoGitRepo) Config() *provisioning.Repository {
- return g.config
-}
-
-// ReadTree implements repository.Repository.
-func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
- var treePath string
- if g.config.Spec.GitHub.Path != "" {
- treePath = g.config.Spec.GitHub.Path
- }
- treePath = safepath.Clean(treePath)
-
- entries := make([]repository.FileTreeEntry, 0, 100)
- err := util.Walk(g.tree.Filesystem(), treePath, func(path string, info fs.FileInfo, err error) error {
- // We already have an error, just pass it onwards.
- if err != nil ||
- // This is the root of the repository (or should pretend to be)
- safepath.Clean(path) == "" || path == treePath ||
- // This is the Git data
- (treePath == "" && (strings.HasPrefix(path, ".git/") || path == ".git")) {
- return err
- }
- if treePath != "" {
- path = strings.TrimPrefix(path, treePath)
- }
- entry := repository.FileTreeEntry{
- Path: strings.TrimLeft(path, "/"),
- Size: info.Size(),
- }
- if !info.IsDir() {
- entry.Blob = true
- // For a real instance, this will likely be based on:
- // https://github.com/go-git/go-git/blob/main/_examples/ls/main.go#L25
- entry.Hash = fmt.Sprintf("TODO/%d", info.Size()) // but not used for
- }
- entries = append(entries, entry)
- return err
- })
- if errors.Is(err, fs.ErrNotExist) {
- // We intentionally ignore this case, as it is expected
- } else if err != nil {
- return nil, fmt.Errorf("walk tree for ref '%s': %w", ref, err)
- }
- return entries, nil
-}
-
-func (g *GoGitRepo) Test(ctx context.Context) (*provisioning.TestResults, error) {
- return &provisioning.TestResults{
- Success: g.tree != nil,
- }, nil
-}
-
-// Update implements repository.Repository.
-func (g *GoGitRepo) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
- return g.Write(ctx, path, ref, data, message)
-}
-
-// Create implements repository.Repository.
-func (g *GoGitRepo) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
- // FIXME: this means we would override files
- return g.Write(ctx, path, ref, data, message)
-}
-
-// Write implements repository.Repository.
-func (g *GoGitRepo) Write(ctx context.Context, fpath string, ref string, data []byte, message string) error {
- if err := verifyPathWithoutRef(fpath, ref); err != nil {
- return err
- }
- fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
-
- // FIXME: this means that won't export empty folders
- // should we create them with a .keep file?
- // For folders, just create the folder and ignore the commit
- if safepath.IsDir(fpath) {
- return g.tree.Filesystem().MkdirAll(fpath, 0750)
- }
-
- dir := safepath.Dir(fpath)
- if dir != "" {
- err := g.tree.Filesystem().MkdirAll(dir, 0750)
- if err != nil {
- return err
- }
- }
-
- file, err := g.tree.Filesystem().Create(fpath)
- if err != nil {
- return err
- }
- _, err = file.Write(data)
- if err != nil {
- return err
- }
-
- _, err = g.tree.Add(fpath)
- if err != nil {
- return err
- }
- return g.maybeCommit(ctx, message)
-}
-
-func (g *GoGitRepo) maybeCommit(ctx context.Context, message string) error {
- // Skip commit for each file
- if !g.opts.PushOnWrites {
- return nil
- }
-
- opts := &git.CommitOptions{
- Author: &object.Signature{
- Name: "grafana",
- },
- }
- sig := repository.GetAuthorSignature(ctx)
- if sig != nil && sig.Name != "" {
- opts.Author.Name = sig.Name
- opts.Author.Email = sig.Email
- opts.Author.When = sig.When
- }
- if opts.Author.When.IsZero() {
- opts.Author.When = time.Now()
- }
-
- _, err := g.tree.Commit(message, opts)
- if errors.Is(err, git.ErrEmptyCommit) {
- return nil // empty commit is fine -- no change
- }
- return err
-}
-
-// Delete implements repository.Repository.
-func (g *GoGitRepo) Delete(ctx context.Context, fpath string, ref string, message string) error {
- if err := verifyPathWithoutRef(fpath, ref); err != nil {
- return err
- }
-
- fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
- if _, err := g.tree.Remove(fpath); err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return repository.ErrFileNotFound
- }
-
- return err
- }
- return g.maybeCommit(ctx, message)
-}
-
-// Read implements repository.Repository.
-func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
- if err := verifyPathWithoutRef(path, ref); err != nil {
- return nil, err
- }
- readPath := safepath.Join(g.config.Spec.GitHub.Path, path)
- stat, err := g.tree.Filesystem().Lstat(readPath)
- if errors.Is(err, fs.ErrNotExist) {
- return nil, repository.ErrFileNotFound
- } else if err != nil {
- return nil, fmt.Errorf("stat path '%s': %w", readPath, err)
- }
- info := &repository.FileInfo{
- Path: path,
- Modified: &metav1.Time{
- Time: stat.ModTime(),
- },
- }
- if !stat.IsDir() {
- f, err := g.tree.Filesystem().Open(readPath)
- if err != nil {
- return nil, fmt.Errorf("open file '%s': %w", readPath, err)
- }
- info.Data, err = io.ReadAll(f)
- if err != nil {
- return nil, fmt.Errorf("read file '%s': %w", readPath, err)
- }
- }
- return info, err
-}
-
-func verifyPathWithoutRef(path string, ref string) error {
- if path == "" {
- return fmt.Errorf("expected path")
- }
- if ref != "" {
- return fmt.Errorf("ref unsupported")
- }
- return nil
-}
-
-// History implements repository.Repository.
-func (g *GoGitRepo) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) {
- return nil, &apierrors.StatusError{
- ErrStatus: metav1.Status{
- Message: "history is not yet implemented",
- Code: http.StatusNotImplemented,
- },
- }
-}
-
-// Validate implements repository.Repository.
-func (g *GoGitRepo) Validate() field.ErrorList {
- return nil
-}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
deleted file mode 100644
index 79a2951e8c8..00000000000
--- a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
+++ /dev/null
@@ -1,1642 +0,0 @@
-package gogit
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "testing"
- "time"
-
- "github.com/go-git/go-billy/v5"
- "github.com/go-git/go-billy/v5/memfs"
- "github.com/go-git/go-git/v5"
- plumbing "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/go-git/go-git/v5/plumbing/transport/client"
- githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
- "github.com/go-git/go-git/v5/plumbing/transport/server"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
- "github.com/go-git/go-git/v5/storage/memory"
- "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
-)
-
-type dummySecret struct{}
-
-func (d *dummySecret) Decrypt(ctx context.Context, encrypted []byte) ([]byte, error) {
- token, ok := os.LookupEnv("gitwraptoken")
- if !ok {
- return nil, fmt.Errorf("missing token in environment")
- }
- return []byte(token), nil
-}
-
-func (d *dummySecret) Encrypt(ctx context.Context, plain []byte) ([]byte, error) {
- panic("not implemented")
-}
-
-// FIXME!! NOTE!!!!!
-// This is really just a sketchpad while trying to get things working
-// the test makes destructive changes to a real git repository :)
-// this should be removed before committing to main (likely sooner)
-// and replaced with integration tests that check the more specific results
-func TestGoGitWrapper(t *testing.T) {
- _, ok := os.LookupEnv("gitwraptoken")
- if !ok {
- t.Skipf("no token found in environment")
- }
-
- ctx := context.Background()
- wrap, err := Clone(ctx, "testdata/clone", &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "ns",
- Name: "unit-tester",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/git-ui-sync-demo",
- Branch: "ryan-test",
- },
- },
- },
- repository.CloneOptions{
- PushOnWrites: false,
- CreateIfNotExists: true,
- Progress: os.Stdout,
- },
- &dummySecret{},
- )
- require.NoError(t, err)
-
- tree, err := wrap.ReadTree(ctx, "")
- require.NoError(t, err)
-
- jj, err := json.MarshalIndent(tree, "", " ")
- require.NoError(t, err)
-
- fmt.Printf("TREE:%s\n", string(jj))
-
- ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
- Name: "xxxxx",
- Email: "rrr@yyyy.zzz",
- When: time.Now(),
- })
-
- for i := 0; i < 10; i++ {
- fname := fmt.Sprintf("deep/path/in/test_%d.txt", i)
- fmt.Printf("Write:%s\n", fname)
- err = wrap.Write(ctx, fname, "", []byte(fmt.Sprintf("body/%d %s", i, time.Now())), "the commit message")
- require.NoError(t, err)
- }
-
- fmt.Printf("push...\n")
- err = wrap.Push(ctx, repository.PushOptions{
- Timeout: 10,
- Progress: os.Stdout,
- })
- require.NoError(t, err)
-}
-
-func TestReadTree(t *testing.T) {
- dir := t.TempDir()
- gitRepo, err := git.PlainInit(dir, false)
- require.NoError(t, err, "failed to init a new git repository")
- tree, err := gitRepo.Worktree()
- require.NoError(t, err, "failed to get worktree")
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- },
- Spec: v0alpha1.RepositorySpec{
- Title: "test",
- Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
- Type: v0alpha1.GitHubRepositoryType,
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/grafana/__unit-test",
- Path: "grafana/",
- Branch: "main",
- },
- },
- Status: v0alpha1.RepositoryStatus{},
- },
- decryptedPassword: "password",
-
- repo: gitRepo,
- tree: &worktree{
- Worktree: tree,
- },
- dir: dir,
- }
-
- err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644)
- require.NoError(t, err, "failed to write test file")
-
- err = os.Mkdir(filepath.Join(dir, "grafana"), 0750)
- require.NoError(t, err, "failed to mkdir grafana")
-
- err = os.WriteFile(filepath.Join(dir, "grafana", "test2.txt"), []byte("test"), 0644)
- require.NoError(t, err, "failed to write grafana/test2 file")
-
- ctx := context.Background()
- entries, err := repo.ReadTree(ctx, "HEAD")
- require.NoError(t, err, "failed to read tree")
-
- // Here is the meat of why this test exists: the ReadTree call should only read the config.Spec.GitHub.Path files.
- // All prefixes are removed (i.e. a file is just its name, not ${Path}/${Name}).
- // And it does not include the directory in the listing, as it pretends to be the root.
- require.Len(t, entries, 1, "entries from ReadTree")
- require.Equal(t, entries[0].Path, "test2.txt", "entry path")
-}
-
-func TestGoGitRepo_History(t *testing.T) {
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- }
-
- // Test History method
- ctx := context.Background()
- _, err := repo.History(ctx, "test.txt", "")
- require.Error(t, err, "History should return an error as it's not implemented")
- require.Contains(t, err.Error(), "history is not yet implemented")
-}
-
-func TestGoGitRepo_Validate(t *testing.T) {
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- }
-
- // Test Validate method
- errs := repo.Validate()
- require.Empty(t, errs, "Validate should return no errors")
-}
-
-func TestGoGitRepo_Read(t *testing.T) {
- // Setup test cases
- tests := []struct {
- name string
- path string
- ref string
- setupMock func(fs billy.Filesystem)
- expectError bool
- errorType error
- checkResult func(t *testing.T, info *repository.FileInfo)
- }{
- {
- name: "successfully read file",
- path: "test.txt",
- ref: "",
- setupMock: func(fs billy.Filesystem) {
- // Create a test file
- f, err := fs.Create("grafana/test.txt")
- require.NoError(t, err, "failed to create test file")
- _, err = f.Write([]byte("test content"))
- require.NoError(t, err, "failed to write test content")
- err = f.Close()
- require.NoError(t, err, "failed to close test file")
- },
- expectError: false,
- checkResult: func(t *testing.T, info *repository.FileInfo) {
- require.Equal(t, "test.txt", info.Path)
- require.Equal(t, "test content", string(info.Data))
- require.NotNil(t, info.Modified)
- },
- },
- {
- name: "empty path",
- path: "",
- ref: "",
- setupMock: func(fs billy.Filesystem) {},
- expectError: true,
- errorType: fmt.Errorf("expected path"),
- },
- {
- name: "ref not supported",
- path: "test.txt",
- ref: "main",
- setupMock: func(fs billy.Filesystem) {},
- expectError: true,
- errorType: fmt.Errorf("ref unsupported"),
- },
- {
- name: "file not found",
- path: "nonexistent.txt",
- ref: "",
- setupMock: func(fs billy.Filesystem) {
- // Don't create the file
- },
- expectError: true,
- errorType: repository.ErrFileNotFound,
- },
- {
- name: "read directory",
- path: "testdir",
- ref: "",
- setupMock: func(fs billy.Filesystem) {
- // Create a test directory
- err := fs.MkdirAll("grafana/testdir", 0755)
- require.NoError(t, err, "failed to create test directory")
- },
- expectError: false,
- checkResult: func(t *testing.T, info *repository.FileInfo) {
- require.Equal(t, "testdir", info.Path)
- require.Nil(t, info.Data)
- require.NotNil(t, info.Modified)
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup filesystem and repo
- fs := memfs.New()
- tt.setupMock(fs)
-
- // Create a worktree with the filesystem
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- tree: &worktree{
- Worktree: &git.Worktree{
- Filesystem: fs,
- },
- },
- }
-
- // Test Read method
- ctx := context.Background()
- info, err := repo.Read(ctx, tt.path, tt.ref)
-
- // Check results
- if tt.expectError {
- require.Error(t, err)
- if tt.errorType != nil {
- if errors.Is(tt.errorType, repository.ErrFileNotFound) {
- require.ErrorIs(t, err, repository.ErrFileNotFound)
- } else {
- require.Contains(t, err.Error(), tt.errorType.Error())
- }
- }
- } else {
- require.NoError(t, err)
- require.NotNil(t, info)
- tt.checkResult(t, info)
- }
- })
- }
-}
-
-func TestGoGitRepo_Delete(t *testing.T) {
- tests := []struct {
- name string
- path string
- ref string
- pushOnWrite bool
- setupMock func(mockTree *MockWorktree)
- expectError bool
- errorType error
- }{
- {
- name: "delete existing file",
- path: "testfile.txt",
- ref: "",
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {
- mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
- },
- expectError: false,
- },
- {
- name: "delete non-existent file",
- path: "nonexistent.txt",
- ref: "",
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {
- mockTree.On("Remove", "grafana/nonexistent.txt").Return(plumbing.Hash{}, fs.ErrNotExist)
- },
- expectError: true,
- errorType: repository.ErrFileNotFound,
- },
- {
- name: "delete with other error",
- path: "testfile.txt",
- ref: "",
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {
- mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, fmt.Errorf("some other error"))
- },
- expectError: true,
- errorType: fmt.Errorf("some other error"),
- },
- {
- name: "empty path",
- path: "",
- ref: "",
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {},
- expectError: true,
- errorType: fmt.Errorf("expected path"),
- },
- {
- name: "with ref",
- path: "testfile.txt",
- ref: "main",
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {
- },
- expectError: true,
- errorType: fmt.Errorf("ref unsupported"),
- },
- {
- name: "delete with push on write enabled",
- path: "testfile.txt",
- ref: "",
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
- mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool {
- return opts.Author != nil &&
- opts.Author.Name == "Test User" &&
- opts.Author.Email == "test@example.com" &&
- opts.Author.When.After(time.Now().Add(-time.Minute)) &&
- opts.Author.When.Before(time.Now().Add(time.Minute))
- })).Return(plumbing.Hash{}, nil)
- },
- expectError: false,
- },
- {
- name: "delete with empty commit",
- path: "testfile.txt",
- ref: "",
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- mockTree.On("Remove", "grafana/testfile.txt").Return(plumbing.Hash{}, nil)
- mockTree.On("Commit", "test delete", mock.MatchedBy(func(opts *git.CommitOptions) bool {
- return opts.Author != nil &&
- opts.Author.Name == "Test User" &&
- opts.Author.Email == "test@example.com" &&
- opts.Author.When.After(time.Now().Add(-time.Minute)) &&
- opts.Author.When.Before(time.Now().Add(time.Minute))
- })).Return(plumbing.Hash{}, git.ErrEmptyCommit)
- },
- expectError: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup filesystem and repo
-
- mockTree := NewMockWorktree(t)
- tt.setupMock(mockTree)
-
- // Create a worktree with the filesystem
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- tree: mockTree,
- opts: repository.CloneOptions{
- PushOnWrites: tt.pushOnWrite,
- },
- }
-
- // Test Delete method
- ctx := context.Background()
- // Set author signature for the test
- ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- })
-
- err := repo.Delete(ctx, tt.path, tt.ref, "test delete")
-
- // Check results
- if tt.expectError {
- require.Error(t, err)
- if tt.errorType != nil {
- if errors.Is(tt.errorType, repository.ErrFileNotFound) {
- require.ErrorIs(t, err, repository.ErrFileNotFound)
- } else {
- require.Contains(t, err.Error(), tt.errorType.Error())
- }
- }
- } else {
- require.NoError(t, err)
- }
-
- mockTree.AssertExpectations(t)
- })
- }
-}
-
-// FIXME: missing coverage for Update / Create because we use Write for both
-// when I think it shouldn't be the case as it's inconsistent with the other repository implementations
-func TestGoGitRepo_Write(t *testing.T) {
- tests := []struct {
- name string
- path string
- ref string
- data []byte
- pushOnWrite bool
- setupMock func(mockTree *MockWorktree)
- expectError bool
- errorType error
- }{
- {
- name: "successful write",
- path: "test.txt",
- ref: "",
- data: []byte("test content"),
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- fs := memfs.New()
- mockTree.On("Filesystem").Return(fs)
- mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil)
- mockTree.On("Commit", "test write", mock.MatchedBy(func(opts *git.CommitOptions) bool {
- return opts.Author != nil &&
- opts.Author.Name == "Test User" &&
- opts.Author.Email == "test@example.com" &&
- opts.Author.When.After(time.Now().Add(-time.Minute)) &&
- opts.Author.When.Before(time.Now().Add(time.Minute))
- })).Return(plumbing.NewHash("def456"), nil)
- },
- expectError: false,
- },
- {
- name: "create folder only",
- path: "testdir/",
- ref: "",
- data: []byte{},
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- fs := memfs.New()
- mockTree.On("Filesystem").Return(fs)
- // No Add or Commit calls expected for directory creation
- },
- expectError: false,
- },
- {
- name: "successful write without commit",
- path: "test.txt",
- ref: "",
- data: []byte("test content"),
- pushOnWrite: false,
- setupMock: func(mockTree *MockWorktree) {
- fs := memfs.New()
- mockTree.On("Filesystem").Return(fs)
- mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash("abc123"), nil)
- },
- expectError: false,
- },
- {
- name: "write with directory creation",
- path: "dir/test.txt",
- ref: "",
- data: []byte("test content"),
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- fs := memfs.New()
- mockTree.On("Filesystem").Return(fs)
- mockTree.On("Add", "grafana/dir/test.txt").Return(plumbing.NewHash("abc123"), nil)
- mockTree.On("Commit", "test write", mock.Anything).Return(plumbing.NewHash("def456"), nil)
- },
- expectError: false,
- },
- {
- name: "error on add",
- path: "test.txt",
- ref: "",
- data: []byte("test content"),
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- fs := memfs.New()
- mockTree.On("Filesystem").Return(fs)
- mockTree.On("Add", "grafana/test.txt").Return(plumbing.NewHash(""), fmt.Errorf("add error"))
- },
- expectError: true,
- errorType: fmt.Errorf("add error"),
- },
- {
- name: "error with ref",
- path: "test.txt",
- ref: "main",
- data: []byte("test content"),
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- // No mock setup needed as it should fail before using the mock
- },
- expectError: true,
- errorType: fmt.Errorf("ref unsupported"),
- },
- {
- name: "empty path",
- path: "",
- ref: "",
- data: []byte("test content"),
- pushOnWrite: true,
- setupMock: func(mockTree *MockWorktree) {
- // No mock setup needed as it should fail before using the mock
- },
- expectError: true,
- errorType: fmt.Errorf("expected path"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup filesystem and repo
- mockTree := NewMockWorktree(t)
- tt.setupMock(mockTree)
-
- // Create a worktree with the filesystem
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- tree: mockTree,
- opts: repository.CloneOptions{
- PushOnWrites: tt.pushOnWrite,
- },
- }
-
- // Test Write method
- ctx := context.Background()
- // Set author signature for the test
- ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- })
-
- err := repo.Update(ctx, tt.path, tt.ref, tt.data, "test write")
-
- // Check results
- if tt.expectError {
- require.Error(t, err)
- if tt.errorType != nil {
- require.Contains(t, err.Error(), tt.errorType.Error())
- }
- } else {
- require.NoError(t, err)
- }
-
- mockTree.AssertExpectations(t)
- })
- }
-}
-
-func TestGoGitRepo_Test(t *testing.T) {
- tests := []struct {
- name string
- treeInitialized bool
- expectedResult bool
- }{
- {
- name: "tree is initialized",
- treeInitialized: true,
- expectedResult: true,
- },
- {
- name: "tree is not initialized",
- treeInitialized: false,
- expectedResult: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup mock tree
- mockTree := NewMockWorktree(t)
-
- // Create repo with or without initialized tree
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- tree: nil,
- }
-
- if tt.treeInitialized {
- repo.tree = mockTree
- }
-
- // Test the Test method
- ctx := context.Background()
- result, err := repo.Test(ctx)
-
- // Verify results
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, tt.expectedResult, result.Success)
- })
- }
-}
-
-func TestGoGitRepo_Config(t *testing.T) {
- // Create a test repository configuration
- testConfig := &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test-repo",
- Namespace: "test-namespace",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- }
-
- // Create a repository instance with the test configuration
- repo := &GoGitRepo{
- config: testConfig,
- tree: NewMockWorktree(t),
- }
-
- // Call the Config method
- result := repo.Config()
-
- // Verify the result
- require.NotNil(t, result)
- require.Equal(t, testConfig, result)
- require.Equal(t, "test-repo", result.Name)
- require.Equal(t, "test-namespace", result.Namespace)
- require.Equal(t, "grafana/", result.Spec.GitHub.Path)
-}
-
-func TestGoGitRepo_Remove(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(t *testing.T) (*GoGitRepo, string)
- expectError bool
- expectedErrMsg string
- }{
- {
- name: "successful removal",
- setupMock: func(t *testing.T) (*GoGitRepo, string) {
- // Create a temporary directory that will be removed
- tempDir, err := os.MkdirTemp("", "test-repo-*")
- require.NoError(t, err)
-
- // Create a repository instance
- repo := &GoGitRepo{
- dir: tempDir,
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test-repo",
- Namespace: "test-namespace",
- },
- },
- }
-
- return repo, tempDir
- },
- expectError: false,
- },
- {
- name: "directory already removed",
- setupMock: func(t *testing.T) (*GoGitRepo, string) {
- // Create a temporary directory
- tempDir, err := os.MkdirTemp("", "test-repo-*")
- require.NoError(t, err)
-
- // Remove it immediately to simulate it being already gone
- err = os.RemoveAll(tempDir)
- require.NoError(t, err)
-
- // Create a repository instance pointing to the removed directory
- repo := &GoGitRepo{
- dir: tempDir,
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test-repo",
- Namespace: "test-namespace",
- },
- },
- }
-
- return repo, tempDir
- },
- expectError: false, // RemoveAll doesn't error if directory doesn't exist
- },
- {
- name: "invalid directory path",
- setupMock: func(t *testing.T) (*GoGitRepo, string) {
- // Create a repository instance with an invalid directory path
- // that should cause an error when trying to remove
- invalidPath := string([]byte{0})
-
- repo := &GoGitRepo{
- dir: invalidPath,
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test-repo",
- Namespace: "test-namespace",
- },
- },
- }
-
- return repo, invalidPath
- },
- expectError: true,
- expectedErrMsg: "invalid argument",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup the test
- repo, _ := tt.setupMock(t)
-
- // Test the Remove method
- ctx := context.Background()
- err := repo.Remove(ctx)
-
- // Verify results
- if tt.expectError {
- require.Error(t, err)
- if tt.expectedErrMsg != "" {
- require.Contains(t, err.Error(), tt.expectedErrMsg)
- }
- } else {
- require.NoError(t, err)
- // Verify the directory no longer exists
- _, statErr := os.Stat(repo.dir)
- require.True(t, os.IsNotExist(statErr), "Directory should not exist after removal")
- }
- })
- }
-}
-
-func TestGoGitRepo_Push(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree)
- pushOpts repository.PushOptions
- expectError bool
- errorType error
- }{
- {
- name: "successful push",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool {
- if o.Auth == nil {
- return false
- }
- // Verify we're using basic auth with expected credentials
- basicAuth, ok := o.Auth.(*githttp.BasicAuth)
- if !ok {
- return false
- }
- return basicAuth.Username == "grafana" && basicAuth.Password == "test-token"
- })).Return(nil)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{},
- expectError: false,
- },
- {
- name: "push error",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(fmt.Errorf("network error"))
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{},
- expectError: true,
- errorType: fmt.Errorf("network error"),
- },
- {
- name: "already up to date",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(git.NoErrAlreadyUpToDate)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{},
- expectError: false,
- },
- {
- name: "push with custom timeout",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{
- Timeout: 5 * time.Minute,
- },
- expectError: false,
- },
- {
- name: "push with custom progress writer",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.MatchedBy(func(o *git.PushOptions) bool {
- return o.Progress != nil && o.Progress != io.Discard
- })).Return(nil)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{
- Progress: &bytes.Buffer{},
- },
- expectError: false,
- },
- {
- name: "push with BeforeFn success",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, mockRepo, nil
- },
- pushOpts: repository.PushOptions{
- BeforeFn: func() error {
- return nil
- },
- },
- expectError: false,
- },
- {
- name: "push with BeforeFn error",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- // No mock expectations since BeforeFn will fail before PushContext is called
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: NewMockRepository(t),
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: true,
- },
- }
-
- return repo, repo.repo.(*MockRepository), nil
- },
- pushOpts: repository.PushOptions{
- BeforeFn: func() error {
- return fmt.Errorf("before function failed")
- },
- },
- expectError: true,
- errorType: fmt.Errorf("before function failed"),
- },
- {
- name: "push with PushOnWrites=false commits changes",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
-
- mockTree := NewMockWorktree(t)
- mockTree.On("Commit", "exported from grafana", mock.MatchedBy(func(o *git.CommitOptions) bool {
- return o.All == true
- })).Return(plumbing.NewHash("abc123"), nil)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- tree: mockTree,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: false,
- },
- }
-
- return repo, mockRepo, mockTree
- },
- pushOpts: repository.PushOptions{},
- expectError: false,
- },
- {
- name: "push with PushOnWrites=false and empty commit",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockRepo := NewMockRepository(t)
- mockRepo.On("PushContext", mock.Anything, mock.Anything).Return(nil)
-
- mockTree := NewMockWorktree(t)
- mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, git.ErrEmptyCommit)
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: mockRepo,
- tree: mockTree,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: false,
- },
- }
-
- return repo, mockRepo, mockTree
- },
- pushOpts: repository.PushOptions{},
- expectError: false,
- },
- {
- name: "push with PushOnWrites=false and commit error",
- setupMock: func(t *testing.T) (*GoGitRepo, *MockRepository, *MockWorktree) {
- mockTree := NewMockWorktree(t)
- mockTree.On("Commit", "exported from grafana", mock.Anything).Return(plumbing.ZeroHash, fmt.Errorf("commit error"))
-
- repo := &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- repo: NewMockRepository(t),
- tree: mockTree,
- decryptedPassword: "test-token",
- opts: repository.CloneOptions{
- PushOnWrites: false,
- },
- }
-
- return repo, repo.repo.(*MockRepository), mockTree
- },
- pushOpts: repository.PushOptions{},
- expectError: true,
- errorType: fmt.Errorf("commit error"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup the test
- repo, mockRepo, mockTree := tt.setupMock(t)
-
- // Test the Push method
- ctx := context.Background()
- err := repo.Push(ctx, tt.pushOpts)
-
- // Verify results
- if tt.expectError {
- require.Error(t, err)
- if tt.errorType != nil {
- require.Contains(t, err.Error(), tt.errorType.Error())
- }
- } else {
- require.NoError(t, err)
- }
-
- // Verify mock expectations if mocks were created
- if mockRepo != nil {
- mockRepo.AssertExpectations(t)
- }
- if mockTree != nil {
- mockTree.AssertExpectations(t)
- }
- })
- }
-}
-
-func TestGoGitRepo_ReadTree(t *testing.T) {
- tests := []struct {
- name string
- setupMock func(t *testing.T) *GoGitRepo
- ref string
- expectError bool
- expectedErrMsg string
- expectedFiles []repository.FileTreeEntry
- }{
- {
- name: "successful read with files",
- setupMock: func(t *testing.T) *GoGitRepo {
- mockFS := memfs.New()
-
- // Create test files in the mock filesystem
- require.NoError(t, mockFS.MkdirAll("grafana/folder1", 0750))
- file1, err := mockFS.Create("grafana/file1.txt")
- require.NoError(t, err)
- _, err = file1.Write([]byte("test content"))
- require.NoError(t, err)
- require.NoError(t, file1.Close())
-
- file2, err := mockFS.Create("grafana/folder1/file2.txt")
- require.NoError(t, err)
- _, err = file2.Write([]byte("nested file content"))
- require.NoError(t, err)
- require.NoError(t, file2.Close())
-
- mockTree := NewMockWorktree(t)
- mockTree.On("Filesystem").Return(mockFS)
-
- return &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "grafana/",
- },
- },
- },
- tree: mockTree,
- }
- },
- ref: "main",
- expectError: false,
- expectedFiles: []repository.FileTreeEntry{
- {Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"},
- {Path: "folder1", Size: 0, Blob: false},
- {Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"},
- },
- },
- {
- name: "filesystem error",
- setupMock: func(t *testing.T) *GoGitRepo {
- mockTree := NewMockWorktree(t)
- mockFS := memfs.New()
-
- // Create a filesystem that will return an error when accessed
- mockTree.On("Filesystem").Return(mockFS)
-
- return &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "non-existent-path/",
- },
- },
- },
- tree: mockTree,
- }
- },
- ref: "main",
- expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries
- expectedFiles: []repository.FileTreeEntry{},
- },
- {
- name: "successful read with empty path",
- setupMock: func(t *testing.T) *GoGitRepo {
- mockFS := memfs.New()
-
- // Create test files in the mock filesystem
- file1, err := mockFS.Create("file1.txt")
- require.NoError(t, err)
- _, err = file1.Write([]byte("test content"))
- require.NoError(t, err)
- require.NoError(t, file1.Close())
-
- require.NoError(t, mockFS.MkdirAll("folder1", 0750))
- file2, err := mockFS.Create("folder1/file2.txt")
- require.NoError(t, err)
- _, err = file2.Write([]byte("nested file content"))
- require.NoError(t, err)
- require.NoError(t, file2.Close())
-
- // Create .git directory which should be ignored
- require.NoError(t, mockFS.MkdirAll(".git", 0750))
- gitFile, err := mockFS.Create(".git/config")
- require.NoError(t, err)
- _, err = gitFile.Write([]byte("git config"))
- require.NoError(t, err)
- require.NoError(t, gitFile.Close())
-
- mockTree := NewMockWorktree(t)
- mockTree.On("Filesystem").Return(mockFS)
-
- return &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "",
- },
- },
- },
- tree: mockTree,
- }
- },
- ref: "main",
- expectError: false,
- expectedFiles: []repository.FileTreeEntry{
- {Path: "file1.txt", Size: 12, Blob: true, Hash: "TODO/12"},
- {Path: "folder1", Size: 0, Blob: false},
- {Path: "folder1/file2.txt", Size: 19, Blob: true, Hash: "TODO/19"},
- },
- },
- {
- name: "filesystem error",
- setupMock: func(t *testing.T) *GoGitRepo {
- mockTree := NewMockWorktree(t)
- mockFS := memfs.New()
-
- // Create a filesystem that will return an error when accessed
- mockTree.On("Filesystem").Return(mockFS)
-
- return &GoGitRepo{
- config: &v0alpha1.Repository{
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- Path: "non-existent-path/",
- },
- },
- },
- tree: mockTree,
- }
- },
- ref: "main",
- expectError: false, // ReadTree handles fs.ErrNotExist by returning empty entries
- expectedFiles: []repository.FileTreeEntry{},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup the test
- repo := tt.setupMock(t)
-
- // Test the ReadTree method
- ctx := context.Background()
- entries, err := repo.ReadTree(ctx, tt.ref)
-
- // Verify results
- if tt.expectError {
- require.Error(t, err)
- if tt.expectedErrMsg != "" {
- require.Contains(t, err.Error(), tt.expectedErrMsg)
- }
- } else {
- require.NoError(t, err)
-
- // Sort entries for consistent comparison
- sort.Slice(entries, func(i, j int) bool {
- return entries[i].Path < entries[j].Path
- })
- sort.Slice(tt.expectedFiles, func(i, j int) bool {
- return tt.expectedFiles[i].Path < tt.expectedFiles[j].Path
- })
-
- require.Equal(t, len(tt.expectedFiles), len(entries), "Number of entries should match")
- for i, expected := range tt.expectedFiles {
- require.Equal(t, expected.Path, entries[i].Path, "Path should match")
- require.Equal(t, expected.Size, entries[i].Size, "Size should match")
- require.Equal(t, expected.Blob, entries[i].Blob, "Blob flag should match")
- if expected.Blob {
- require.Equal(t, expected.Hash, entries[i].Hash, "Hash should match")
- }
- }
- }
-
- // Verify mock expectations
- repo.tree.(*MockWorktree).AssertExpectations(t)
- })
- }
-}
-
-func TestClone(t *testing.T) {
- tests := []struct {
- name string
- root string
- config *v0alpha1.Repository
- createRepo bool
- opts repository.CloneOptions
- setupMock func(secrets *secrets.MockService)
- expectError bool
- errorMsg string
- }{
- {
- name: "successful clone",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "main",
- },
- },
- },
- createRepo: true,
- opts: repository.CloneOptions{
- PushOnWrites: false,
- },
- setupMock: func(mockSecrets *secrets.MockService) {
- mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
- },
- expectError: false,
- },
- {
- name: "successful clone with create if not exists",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "non-existent-branch",
- },
- },
- },
- createRepo: true,
- opts: repository.CloneOptions{
- PushOnWrites: false,
- CreateIfNotExists: true,
- },
- setupMock: func(mockSecrets *secrets.MockService) {
- mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
- },
- expectError: false,
- },
- {
- name: "timeout cancellation",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "main",
- },
- },
- },
- opts: repository.CloneOptions{
- Timeout: 1 * time.Millisecond, // Very short timeout to trigger cancellation
- },
- setupMock: func(mockSecrets *secrets.MockService) {
- mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
- // Simulate a slow operation that will be cancelled by timeout
- time.Sleep(20 * time.Millisecond)
- },
- expectError: true,
- errorMsg: "context deadline exceeded",
- },
- {
- name: "empty root",
- root: "",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {},
- expectError: true,
- errorMsg: "missing root config",
- },
- {
- name: "missing namespace",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Name: "test-repo",
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {},
- expectError: true,
- errorMsg: "missing namespace",
- },
- {
- name: "missing name",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {},
- expectError: true,
- errorMsg: "missing name",
- },
- {
- name: "beforeFn error",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- },
- opts: repository.CloneOptions{
- BeforeFn: func() error {
- return fmt.Errorf("beforeFn error")
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {},
- expectError: true,
- errorMsg: "beforeFn error",
- },
- {
- name: "secret decryption error",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- EncryptedToken: []byte("test-token"),
- },
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {
- mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), fmt.Errorf("error decrypting token"))
- },
- expectError: true,
- errorMsg: "error decrypting token",
- },
- {
- name: "clone error",
- root: "testdata/clone",
- config: &v0alpha1.Repository{
- ObjectMeta: v1.ObjectMeta{
- Namespace: "test-ns",
- Name: "test-repo",
- },
- Spec: v0alpha1.RepositorySpec{
- GitHub: &v0alpha1.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "main",
- },
- },
- },
- setupMock: func(mockSecrets *secrets.MockService) {
- mockSecrets.On("Decrypt", mock.Anything, mock.Anything).Return([]byte("test-token"), nil)
- },
- expectError: true,
- errorMsg: "clone error",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup test environment
- mockSecrets := secrets.NewMockService(t)
- tt.setupMock(mockSecrets)
-
- // Create a temporary directory for each test
- if tt.root != "" {
- tempDir := t.TempDir()
- tt.root = tempDir
- }
- if tt.createRepo {
- tt.config.Spec.GitHub.URL = createTestRepo(t)
- }
-
- // Execute the test
- ctx := context.Background()
- repo, err := Clone(ctx, tt.root, tt.config, tt.opts, mockSecrets)
-
- // Verify results
- if tt.expectError {
- require.Error(t, err)
- require.Contains(t, err.Error(), tt.errorMsg)
- require.Nil(t, repo)
- } else {
- require.NoError(t, err)
- require.NotNil(t, repo)
-
- // Verify the returned repository
- gitRepo, ok := repo.(*GoGitRepo)
- require.True(t, ok)
- require.Equal(t, tt.config, gitRepo.config)
- require.NotEmpty(t, gitRepo.dir)
- require.NotNil(t, gitRepo.tree)
- require.NotNil(t, gitRepo.repo)
-
- // Clean up
- err = repo.Remove(ctx)
- require.NoError(t, err)
- }
- mockSecrets.AssertExpectations(t)
- })
- }
-}
-
-func createTestRepo(t *testing.T) string {
- // Create memory filesystem
- fs := memfs.New()
-
- // Initialize new repo
- repo, err := git.Init(memory.NewStorage(), fs)
- require.NoError(t, err, "Failed to init test repo")
-
- w, err := repo.Worktree()
- require.NoError(t, err, "Failed to get worktree")
-
- // Create a dummy file
- f, err := fs.Create("README.md")
- require.NoError(t, err, "Failed to create file")
- _, err = f.Write([]byte("Hello, world!"))
- require.NoError(t, err, "Failed to write content")
- err = f.Close()
- require.NoError(t, err, "Failed to close file")
-
- // Add and commit the file
- _, err = w.Add("README.md")
- require.NoError(t, err, "Failed to add file")
-
- // Create initial commit
- _, err = w.Commit("initial commit", &git.CommitOptions{
- Author: &object.Signature{
- Name: "Test User",
- Email: "test@example.com",
- When: time.Now(),
- },
- })
- require.NoError(t, err, "Failed to commit")
- // Create a branch
- headRef, err := repo.Head()
- require.NoError(t, err, "Failed to get HEAD reference")
-
- // Create a new branch reference pointing to the current HEAD commit
- branchRef := plumbing.NewBranchReferenceName("main")
- ref := plumbing.NewHashReference(branchRef, headRef.Hash())
-
- // Save the reference to create the branch
- err = repo.Storer.SetReference(ref)
- require.NoError(t, err, "Failed to create branch")
-
- // Checkout the new branch
- err = w.Checkout(&git.CheckoutOptions{
- Branch: branchRef,
- })
- require.NoError(t, err, "Failed to checkout branch")
-
- // Create a map of repositories for the server
- repos := make(map[string]*git.Repository)
- repos["test-repo.git"] = repo
-
- // Create and install the server
- loader := server.MapLoader{
- "file://test-repo.git": repo.Storer,
- }
- srv := server.NewServer(loader)
- client.InstallProtocol("file", srv)
-
- return "file://test-repo.git"
-}
diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local/local.go
similarity index 90%
rename from pkg/registry/apis/provisioning/repository/local.go
rename to pkg/registry/apis/provisioning/repository/local/local.go
index 202bb295a5d..5f63b83cfa1 100644
--- a/pkg/registry/apis/provisioning/repository/local.go
+++ b/pkg/registry/apis/provisioning/repository/local/local.go
@@ -1,4 +1,4 @@
-package repository
+package local
import (
"context"
@@ -23,6 +23,7 @@ import (
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
@@ -75,9 +76,9 @@ func (r *LocalFolderResolver) LocalPath(p string) (string, error) {
}
var (
- _ Repository = (*localRepository)(nil)
- _ Writer = (*localRepository)(nil)
- _ Reader = (*localRepository)(nil)
+ _ repository.Repository = (*localRepository)(nil)
+ _ repository.Writer = (*localRepository)(nil)
+ _ repository.Reader = (*localRepository)(nil)
)
type localRepository struct {
@@ -147,17 +148,17 @@ func (r *localRepository) Validate() field.ErrorList {
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
path := field.NewPath("spec", "local", "path")
if r.config.Spec.Local.Path == "" {
- return fromFieldError(field.Required(path, "no path is configured")), nil
+ return repository.FromFieldError(field.Required(path, "no path is configured")), nil
}
_, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
if err != nil {
- return fromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
+ return repository.FromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
}
_, err = os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) {
- return fromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
+ return repository.FromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
}
return &provisioning.TestResults{
@@ -176,7 +177,7 @@ func (r *localRepository) validateRequest(ref string) error {
}
// ReadResource implements provisioning.Repository.
-func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*FileInfo, error) {
+func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*repository.FileInfo, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
@@ -184,13 +185,13 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
actualPath := safepath.Join(r.path, filePath)
info, err := os.Stat(actualPath)
if errors.Is(err, os.ErrNotExist) {
- return nil, ErrFileNotFound
+ return nil, repository.ErrFileNotFound
} else if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
if info.IsDir() {
- return &FileInfo{
+ return &repository.FileInfo{
Path: filePath,
Modified: &metav1.Time{
Time: info.ModTime(),
@@ -209,7 +210,7 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
return nil, fmt.Errorf("calculate hash of file: %w", err)
}
- return &FileInfo{
+ return &repository.FileInfo{
Path: filePath,
Data: data,
Hash: hash,
@@ -220,7 +221,7 @@ func (r *localRepository) Read(ctx context.Context, filePath string, ref string)
}
// ReadResource implements provisioning.Repository.
-func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
@@ -228,16 +229,16 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
// Return an empty list when folder does not exist
_, err := os.Stat(r.path)
if errors.Is(err, fs.ErrNotExist) {
- return []FileTreeEntry{}, nil
+ return []repository.FileTreeEntry{}, nil
}
rootlen := len(r.path)
- entries := make([]FileTreeEntry, 0, 100)
+ entries := make([]repository.FileTreeEntry, 0, 100)
err = filepath.Walk(r.path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
- entry := FileTreeEntry{
+ entry := repository.FileTreeEntry{
Path: strings.TrimLeft(path[rootlen:], "/"),
Size: info.Size(),
}
@@ -265,7 +266,7 @@ func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeE
func (r *localRepository) calculateFileHash(path string) (string, int64, error) {
// Treats https://securego.io/docs/rules/g304.html
if !safepath.InDir(path, r.path) {
- return "", 0, ErrFileNotFound
+ return "", 0, repository.ErrFileNotFound
}
// We've already made sure the path is safe, so we'll ignore the gosec lint.
@@ -331,7 +332,7 @@ func (r *localRepository) Update(ctx context.Context, path string, ref string, d
f, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
- return ErrFileNotFound
+ return repository.ErrFileNotFound
}
if f.IsDir() {
return apierrors.NewBadRequest("path exists but it is a directory")
diff --git a/pkg/registry/apis/provisioning/repository/local_test.go b/pkg/registry/apis/provisioning/repository/local/local_test.go
similarity index 98%
rename from pkg/registry/apis/provisioning/repository/local_test.go
rename to pkg/registry/apis/provisioning/repository/local/local_test.go
index 5f4e9985c9b..7895da24995 100644
--- a/pkg/registry/apis/provisioning/repository/local_test.go
+++ b/pkg/registry/apis/provisioning/repository/local/local_test.go
@@ -1,4 +1,4 @@
-package repository
+package local
import (
"context"
@@ -19,6 +19,7 @@ import (
field "k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
func TestLocalResolver(t *testing.T) {
@@ -703,7 +704,7 @@ func TestLocalRepository_Update(t *testing.T) {
ref: "",
data: []byte("content"),
comment: "",
- expectedErr: ErrFileNotFound,
+ expectedErr: repository.ErrFileNotFound,
},
{
name: "update directory",
@@ -1174,7 +1175,7 @@ func TestLocalRepository_Read(t *testing.T) {
path string
ref string
expectedErr error
- expected *FileInfo
+ expected *repository.FileInfo
}{
{
name: "read existing file",
@@ -1203,7 +1204,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "test-file.txt",
- expected: &FileInfo{
+ expected: &repository.FileInfo{
Path: "test-file.txt",
Modified: &metav1.Time{Time: time.Now()},
Data: []byte("test content"),
@@ -1231,7 +1232,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "non-existent-file.txt",
- expectedErr: ErrFileNotFound,
+ expectedErr: repository.ErrFileNotFound,
},
{
name: "read with ref should fail",
@@ -1289,7 +1290,7 @@ func TestLocalRepository_Read(t *testing.T) {
return tempDir, repo
},
path: "test-dir",
- expected: &FileInfo{
+ expected: &repository.FileInfo{
Path: "test-dir",
Modified: &metav1.Time{Time: time.Now()},
},
@@ -1327,7 +1328,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
setup func(t *testing.T) (string, *localRepository)
ref string
expectedErr error
- expected []FileTreeEntry
+ expected []repository.FileTreeEntry
}{
{
name: "read empty directory",
@@ -1349,7 +1350,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
- expected: []FileTreeEntry{},
+ expected: []repository.FileTreeEntry{},
expectedErr: nil,
},
{
@@ -1379,7 +1380,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
- expected: []FileTreeEntry{
+ expected: []repository.FileTreeEntry{
{Path: "file1.txt", Blob: true, Size: 8},
{Path: "file2.txt", Blob: true, Size: 8},
{Path: "subdir/", Blob: false},
@@ -1432,7 +1433,7 @@ func TestLocalRepository_ReadTree(t *testing.T) {
return tempDir, repo
},
- expected: []FileTreeEntry{},
+ expected: []repository.FileTreeEntry{},
expectedErr: nil,
},
}
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/github.go b/pkg/registry/apis/provisioning/repository/nanogit/github.go
deleted file mode 100644
index 1305a79a014..00000000000
--- a/pkg/registry/apis/provisioning/repository/nanogit/github.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package nanogit
-
-import (
- "context"
- "strings"
-
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
- "k8s.io/apimachinery/pkg/util/validation/field"
-
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
-)
-
-// githubRepository is a repository implementation that integrates both a GitHub API-backed repository and a nanogit-based repository.
-// It combines the features of the GitHub API with those of a standard Git repository.
-// This is an interim solution to support both backends within a single repository abstraction.
-// Once nanogit is fully integrated, functionality from GithubRepository should be migrated here, and this type should extend the nanogit.GitRepository interface.
-type githubRepository struct {
- apiRepo repository.GithubRepository
- nanogitRepo repository.GitRepository
-}
-
-func NewGithubRepository(
- apiRepo repository.GithubRepository,
- nanogitRepo repository.GitRepository,
-) repository.GithubRepository {
- return &githubRepository{
- apiRepo: apiRepo,
- nanogitRepo: nanogitRepo,
- }
-}
-
-func (r *githubRepository) Config() *provisioning.Repository {
- return r.nanogitRepo.Config()
-}
-
-func (r *githubRepository) Owner() string {
- return r.apiRepo.Owner()
-}
-
-func (r *githubRepository) Repo() string {
- return r.apiRepo.Repo()
-}
-
-func (r *githubRepository) Client() pgh.Client {
- return r.apiRepo.Client()
-}
-
-// Validate extends the nanogit repo validation with github specific validation
-func (r *githubRepository) Validate() (list field.ErrorList) {
- cfg := r.nanogitRepo.Config()
- gh := cfg.Spec.GitHub
- if gh == nil {
- list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
- return list
- }
- if gh.URL == "" {
- list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
- } else {
- _, _, err := repository.ParseOwnerRepoGithub(gh.URL)
- if err != nil {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
- } else if !strings.HasPrefix(gh.URL, "https://github.com/") {
- list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
- }
- }
-
- if len(list) > 0 {
- return list
- }
-
- return r.nanogitRepo.Validate()
-}
-
-// Test implements provisioning.Repository.
-func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
- return r.apiRepo.Test(ctx)
-}
-
-// ReadResource implements provisioning.Repository.
-func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) {
- return r.nanogitRepo.Read(ctx, filePath, ref)
-}
-
-func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
- return r.nanogitRepo.ReadTree(ctx, ref)
-}
-
-func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
- return r.nanogitRepo.Create(ctx, path, ref, data, comment)
-}
-
-func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
- return r.nanogitRepo.Update(ctx, path, ref, data, comment)
-}
-
-func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
- return r.nanogitRepo.Write(ctx, path, ref, data, message)
-}
-
-func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
- return r.nanogitRepo.Delete(ctx, path, ref, comment)
-}
-
-func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
- // Github API provides avatar URLs which nanogit does not, so we delegate to the github repo.
- return r.apiRepo.History(ctx, path, ref)
-}
-
-func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
- return r.nanogitRepo.LatestRef(ctx)
-}
-
-func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) {
- return r.nanogitRepo.CompareFiles(ctx, base, ref)
-}
-
-// ResourceURLs implements RepositoryWithURLs.
-func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) {
- return r.apiRepo.ResourceURLs(ctx, file)
-}
-
-func (r *githubRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
- return r.nanogitRepo.Clone(ctx, opts)
-}
diff --git a/pkg/registry/apis/provisioning/repository/nanogit/github_test.go b/pkg/registry/apis/provisioning/repository/nanogit/github_test.go
deleted file mode 100644
index ef878a27c5c..00000000000
--- a/pkg/registry/apis/provisioning/repository/nanogit/github_test.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package nanogit
-
-import (
- "context"
- "testing"
-
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
- "github.com/stretchr/testify/require"
- "k8s.io/apimachinery/pkg/util/validation/field"
-)
-
-func TestGithubRepository(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- // Create a proper config for testing
- expectedConfig := &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "main",
- },
- },
- }
-
- // Set up mock expectations for the methods that exist
- gitRepo.EXPECT().Config().Return(expectedConfig)
- apiRepo.EXPECT().Owner().Return("test")
- apiRepo.EXPECT().Repo().Return("repo")
- mockClient := pgh.NewMockClient(t)
- apiRepo.EXPECT().Client().Return(mockClient)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
-
- t.Run("delegates config to nanogit repo", func(t *testing.T) {
- result := repo.Config()
- require.Equal(t, expectedConfig, result)
- })
-
- t.Run("delegates owner to api repo", func(t *testing.T) {
- result := repo.Owner()
- require.Equal(t, "test", result)
- })
-
- t.Run("delegates repo to api repo", func(t *testing.T) {
- result := repo.Repo()
- require.Equal(t, "repo", result)
- })
-
- t.Run("delegates client to api repo", func(t *testing.T) {
- result := repo.Client()
- require.Equal(t, mockClient, result)
- })
-}
-
-func TestGithubRepositoryDelegation(t *testing.T) {
- ctx := context.Background()
-
- t.Run("delegates test to api repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedResult := &provisioning.TestResults{
- Code: 200,
- Success: true,
- }
-
- apiRepo.EXPECT().Test(ctx).Return(expectedResult, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.Test(ctx)
-
- require.NoError(t, err)
- require.Equal(t, expectedResult, result)
- })
-
- t.Run("delegates read to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedFileInfo := &repository.FileInfo{
- Path: "test.yaml",
- Data: []byte("test data"),
- Ref: "main",
- Hash: "abc123",
- }
-
- gitRepo.EXPECT().Read(ctx, "test.yaml", "main").Return(expectedFileInfo, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.Read(ctx, "test.yaml", "main")
-
- require.NoError(t, err)
- require.Equal(t, expectedFileInfo, result)
- })
-
- t.Run("delegates read tree to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedEntries := []repository.FileTreeEntry{
- {Path: "file1.yaml", Size: 100, Hash: "hash1", Blob: true},
- {Path: "dir/", Size: 0, Hash: "hash2", Blob: false},
- }
-
- gitRepo.EXPECT().ReadTree(ctx, "main").Return(expectedEntries, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.ReadTree(ctx, "main")
-
- require.NoError(t, err)
- require.Equal(t, expectedEntries, result)
- })
-
- t.Run("delegates create to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- data := []byte("test content")
- gitRepo.EXPECT().Create(ctx, "new-file.yaml", "main", data, "Create new file").Return(nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- err := repo.Create(ctx, "new-file.yaml", "main", data, "Create new file")
-
- require.NoError(t, err)
- })
-
- t.Run("delegates update to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- data := []byte("updated content")
- gitRepo.EXPECT().Update(ctx, "existing-file.yaml", "main", data, "Update file").Return(nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- err := repo.Update(ctx, "existing-file.yaml", "main", data, "Update file")
-
- require.NoError(t, err)
- })
-
- t.Run("delegates write to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- data := []byte("file content")
- gitRepo.EXPECT().Write(ctx, "file.yaml", "main", data, "Write file").Return(nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- err := repo.Write(ctx, "file.yaml", "main", data, "Write file")
-
- require.NoError(t, err)
- })
-
- t.Run("delegates delete to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- gitRepo.EXPECT().Delete(ctx, "file.yaml", "main", "Delete file").Return(nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- err := repo.Delete(ctx, "file.yaml", "main", "Delete file")
-
- require.NoError(t, err)
- })
-
- t.Run("delegates history to api repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedHistory := []provisioning.HistoryItem{
- {
- Ref: "commit1",
- Message: "First commit",
- Authors: []provisioning.Author{{Name: "Test User"}},
- },
- }
-
- apiRepo.EXPECT().History(ctx, "file.yaml", "main").Return(expectedHistory, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.History(ctx, "file.yaml", "main")
-
- require.NoError(t, err)
- require.Equal(t, expectedHistory, result)
- })
-
- t.Run("delegates latest ref to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedRef := "abc123def456"
- gitRepo.EXPECT().LatestRef(ctx).Return(expectedRef, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.LatestRef(ctx)
-
- require.NoError(t, err)
- require.Equal(t, expectedRef, result)
- })
-
- t.Run("delegates compare files to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- expectedChanges := []repository.VersionedFileChange{
- {
- Action: repository.FileActionCreated,
- Path: "new-file.yaml",
- Ref: "feature-branch",
- },
- }
-
- gitRepo.EXPECT().CompareFiles(ctx, "main", "feature-branch").Return(expectedChanges, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.CompareFiles(ctx, "main", "feature-branch")
-
- require.NoError(t, err)
- require.Equal(t, expectedChanges, result)
- })
-
- t.Run("delegates resource URLs to api repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- fileInfo := &repository.FileInfo{
- Path: "dashboard.json",
- Ref: "main",
- Hash: "hash123",
- }
-
- expectedURLs := &provisioning.ResourceURLs{
- SourceURL: "https://github.com/test/repo/blob/main/dashboard.json",
- RepositoryURL: "https://github.com/test/repo",
- NewPullRequestURL: "https://github.com/test/repo/compare/main...feature",
- }
-
- apiRepo.EXPECT().ResourceURLs(ctx, fileInfo).Return(expectedURLs, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.ResourceURLs(ctx, fileInfo)
-
- require.NoError(t, err)
- require.Equal(t, expectedURLs, result)
- })
-
- t.Run("delegates clone to nanogit repo", func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
- mockClonedRepo := repository.NewMockClonedRepository(t)
-
- opts := repository.CloneOptions{
- CreateIfNotExists: true,
- PushOnWrites: true,
- }
-
- gitRepo.EXPECT().Clone(ctx, opts).Return(mockClonedRepo, nil)
-
- repo := NewGithubRepository(apiRepo, gitRepo)
- result, err := repo.Clone(ctx, opts)
-
- require.NoError(t, err)
- require.Equal(t, mockClonedRepo, result)
- })
-}
-
-func TestGithubRepositoryValidation(t *testing.T) {
- tests := []struct {
- name string
- config *provisioning.Repository
- expectedErrors int
- }{
- {
- name: "missing github config",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- },
- },
- expectedErrors: 1,
- },
- {
- name: "missing github url",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- GitHub: &provisioning.GitHubRepositoryConfig{
- Branch: "main",
- },
- },
- },
- expectedErrors: 1,
- },
- {
- name: "invalid github url",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "invalid-url",
- Branch: "main",
- },
- },
- },
- expectedErrors: 1,
- },
- {
- name: "non-github url",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://gitlab.com/test/repo",
- Branch: "main",
- },
- },
- },
- expectedErrors: 1,
- },
- {
- name: "valid github config",
- config: &provisioning.Repository{
- Spec: provisioning.RepositorySpec{
- Type: provisioning.GitHubRepositoryType,
- GitHub: &provisioning.GitHubRepositoryConfig{
- URL: "https://github.com/test/repo",
- Branch: "main",
- },
- },
- },
- expectedErrors: 0,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- apiRepo := repository.NewMockGithubRepository(t)
- gitRepo := repository.NewMockGitRepository(t)
-
- // Set up mock expectations
- gitRepo.EXPECT().Config().Return(tt.config)
- if tt.expectedErrors == 0 {
- // If no validation errors expected, nanogit validation should be called
- gitRepo.EXPECT().Validate().Return(field.ErrorList{})
- }
-
- repo := NewGithubRepository(apiRepo, gitRepo)
-
- result := repo.Validate()
- require.Len(t, result, tt.expectedErrors)
- })
- }
-}
diff --git a/pkg/registry/apis/provisioning/repository/reader_mock.go b/pkg/registry/apis/provisioning/repository/reader_mock.go
index f657d10914d..dd6a73ab567 100644
--- a/pkg/registry/apis/provisioning/repository/reader_mock.go
+++ b/pkg/registry/apis/provisioning/repository/reader_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go
index fcd40d5ba80..9edddb03fad 100644
--- a/pkg/registry/apis/provisioning/repository/repository.go
+++ b/pkg/registry/apis/provisioning/repository/repository.go
@@ -2,9 +2,7 @@ package repository
import (
"context"
- "io"
"net/http"
- "time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -67,47 +65,6 @@ type FileInfo struct {
Modified *metav1.Time
}
-//go:generate mockery --name CloneFn --structname MockCloneFn --inpackage --filename clone_fn_mock.go --with-expecter
-type CloneFn func(ctx context.Context, opts CloneOptions) (ClonedRepository, error)
-
-type CloneOptions struct {
- // If the branch does not exist, create it
- CreateIfNotExists bool
-
- // Push on every write
- PushOnWrites bool
-
- // Maximum allowed size for repository clone in bytes (0 means no limit)
- MaxSize int64
-
- // Maximum time allowed for clone operation in seconds (0 means no limit)
- Timeout time.Duration
-
- // Progress is the writer to report progress to
- Progress io.Writer
-
- // BeforeFn is called before the clone operation starts
- BeforeFn func() error
-}
-
-//go:generate mockery --name ClonableRepository --structname MockClonableRepository --inpackage --filename clonable_repository_mock.go --with-expecter
-type ClonableRepository interface {
- Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error)
-}
-
-type PushOptions struct {
- Timeout time.Duration
- Progress io.Writer
- BeforeFn func() error
-}
-
-//go:generate mockery --name ClonedRepository --structname MockClonedRepository --inpackage --filename cloned_repository_mock.go --with-expecter
-type ClonedRepository interface {
- ReaderWriter
- Push(ctx context.Context, opts PushOptions) error
- Remove(ctx context.Context) error
-}
-
// An entry in the file tree, as returned by 'ReadFileTree'. Like FileInfo, but contains less information.
type FileTreeEntry struct {
// The path to the file from the base path given (if any).
diff --git a/pkg/registry/apis/provisioning/repository/stageable_repository_mock.go b/pkg/registry/apis/provisioning/repository/stageable_repository_mock.go
new file mode 100644
index 00000000000..e1f7d619823
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/stageable_repository_mock.go
@@ -0,0 +1,95 @@
+// Code generated by mockery v2.52.4. DO NOT EDIT.
+
+package repository
+
+import (
+ context "context"
+
+ mock "github.com/stretchr/testify/mock"
+)
+
+// MockStageableRepository is an autogenerated mock type for the StageableRepository type
+type MockStageableRepository struct {
+ mock.Mock
+}
+
+type MockStageableRepository_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockStageableRepository) EXPECT() *MockStageableRepository_Expecter {
+ return &MockStageableRepository_Expecter{mock: &_m.Mock}
+}
+
+// Stage provides a mock function with given fields: ctx, opts
+func (_m *MockStageableRepository) Stage(ctx context.Context, opts StageOptions) (StagedRepository, error) {
+ ret := _m.Called(ctx, opts)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Stage")
+ }
+
+ var r0 StagedRepository
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, StageOptions) (StagedRepository, error)); ok {
+ return rf(ctx, opts)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, StageOptions) StagedRepository); ok {
+ r0 = rf(ctx, opts)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(StagedRepository)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, StageOptions) error); ok {
+ r1 = rf(ctx, opts)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MockStageableRepository_Stage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stage'
+type MockStageableRepository_Stage_Call struct {
+ *mock.Call
+}
+
+// Stage is a helper method to define mock.On call
+// - ctx context.Context
+// - opts StageOptions
+func (_e *MockStageableRepository_Expecter) Stage(ctx interface{}, opts interface{}) *MockStageableRepository_Stage_Call {
+ return &MockStageableRepository_Stage_Call{Call: _e.mock.On("Stage", ctx, opts)}
+}
+
+func (_c *MockStageableRepository_Stage_Call) Run(run func(ctx context.Context, opts StageOptions)) *MockStageableRepository_Stage_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(StageOptions))
+ })
+ return _c
+}
+
+func (_c *MockStageableRepository_Stage_Call) Return(_a0 StagedRepository, _a1 error) *MockStageableRepository_Stage_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MockStageableRepository_Stage_Call) RunAndReturn(run func(context.Context, StageOptions) (StagedRepository, error)) *MockStageableRepository_Stage_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// NewMockStageableRepository creates a new instance of MockStageableRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockStageableRepository(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockStageableRepository {
+ mock := &MockStageableRepository{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/registry/apis/provisioning/repository/staged.go b/pkg/registry/apis/provisioning/repository/staged.go
new file mode 100644
index 00000000000..6eb0aed357a
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/staged.go
@@ -0,0 +1,71 @@
+package repository
+
+import (
+ context "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/nanogit"
+)
+
+type StageOptions struct {
+ // Push on every write
+ PushOnWrites bool
+ // Maximum time allowed for clone operation in seconds (0 means no limit)
+ Timeout time.Duration
+}
+
+//go:generate mockery --name StageableRepository --structname MockStageableRepository --inpackage --filename stageable_repository_mock.go --with-expecter
+type StageableRepository interface {
+ Stage(ctx context.Context, opts StageOptions) (StagedRepository, error)
+}
+
+//go:generate mockery --name StagedRepository --structname MockStagedRepository --inpackage --filename staged_repository_mock.go --with-expecter
+type StagedRepository interface {
+ ReaderWriter
+ Push(ctx context.Context) error
+ Remove(ctx context.Context) error
+}
+
+// WrapWithStageAndPushIfPossible attempts to stage the given repository. If staging is supported,
+// it runs the provided function on the staged repository, then pushes any changes and cleans up the staged repository.
+// If staging is not supported, it runs the function on the original repository without pushing.
+// The 'staged' argument to the function indicates whether a staged repository was used.
+func WrapWithStageAndPushIfPossible(
+ ctx context.Context,
+ repo Repository,
+ stageOptions StageOptions,
+ fn func(repo Repository, staged bool) error,
+) error {
+ stageable, ok := repo.(StageableRepository)
+ if !ok {
+ return fn(repo, false)
+ }
+
+ staged, err := stageable.Stage(ctx, stageOptions)
+ if err != nil {
+ return fmt.Errorf("stage repository: %w", err)
+ }
+
+ // We don't, we simply log it
+ // FIXME: should we handle this differently?
+ defer func() {
+ if err := staged.Remove(ctx); err != nil {
+ logging.FromContext(ctx).Error("failed to remove staged repository after export", "err", err)
+ }
+ }()
+
+ if err := fn(staged, true); err != nil {
+ return err
+ }
+
+ if err = staged.Push(ctx); err != nil {
+ if errors.Is(err, nanogit.ErrNothingToPush) {
+ return nil // OK, already pushed
+ }
+ return fmt.Errorf("wrapped push error: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/repository/cloned_repository_mock.go b/pkg/registry/apis/provisioning/repository/staged_repository_mock.go
similarity index 54%
rename from pkg/registry/apis/provisioning/repository/cloned_repository_mock.go
rename to pkg/registry/apis/provisioning/repository/staged_repository_mock.go
index bcb633f9fb7..916b6cc678e 100644
--- a/pkg/registry/apis/provisioning/repository/cloned_repository_mock.go
+++ b/pkg/registry/apis/provisioning/repository/staged_repository_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
@@ -11,21 +11,21 @@ import (
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
-// MockClonedRepository is an autogenerated mock type for the ClonedRepository type
-type MockClonedRepository struct {
+// MockStagedRepository is an autogenerated mock type for the StagedRepository type
+type MockStagedRepository struct {
mock.Mock
}
-type MockClonedRepository_Expecter struct {
+type MockStagedRepository_Expecter struct {
mock *mock.Mock
}
-func (_m *MockClonedRepository) EXPECT() *MockClonedRepository_Expecter {
- return &MockClonedRepository_Expecter{mock: &_m.Mock}
+func (_m *MockStagedRepository) EXPECT() *MockStagedRepository_Expecter {
+ return &MockStagedRepository_Expecter{mock: &_m.Mock}
}
// Config provides a mock function with no fields
-func (_m *MockClonedRepository) Config() *v0alpha1.Repository {
+func (_m *MockStagedRepository) Config() *v0alpha1.Repository {
ret := _m.Called()
if len(ret) == 0 {
@@ -44,35 +44,35 @@ func (_m *MockClonedRepository) Config() *v0alpha1.Repository {
return r0
}
-// MockClonedRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
-type MockClonedRepository_Config_Call struct {
+// MockStagedRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
+type MockStagedRepository_Config_Call struct {
*mock.Call
}
// Config is a helper method to define mock.On call
-func (_e *MockClonedRepository_Expecter) Config() *MockClonedRepository_Config_Call {
- return &MockClonedRepository_Config_Call{Call: _e.mock.On("Config")}
+func (_e *MockStagedRepository_Expecter) Config() *MockStagedRepository_Config_Call {
+ return &MockStagedRepository_Config_Call{Call: _e.mock.On("Config")}
}
-func (_c *MockClonedRepository_Config_Call) Run(run func()) *MockClonedRepository_Config_Call {
+func (_c *MockStagedRepository_Config_Call) Run(run func()) *MockStagedRepository_Config_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
-func (_c *MockClonedRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockClonedRepository_Config_Call {
+func (_c *MockStagedRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockStagedRepository_Config_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockClonedRepository_Config_Call {
+func (_c *MockStagedRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockStagedRepository_Config_Call {
_c.Call.Return(run)
return _c
}
// Create provides a mock function with given fields: ctx, path, ref, data, message
-func (_m *MockClonedRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
+func (_m *MockStagedRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -89,8 +89,8 @@ func (_m *MockClonedRepository) Create(ctx context.Context, path string, ref str
return r0
}
-// MockClonedRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
-type MockClonedRepository_Create_Call struct {
+// MockStagedRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
+type MockStagedRepository_Create_Call struct {
*mock.Call
}
@@ -100,29 +100,29 @@ type MockClonedRepository_Create_Call struct {
// - ref string
// - data []byte
// - message string
-func (_e *MockClonedRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Create_Call {
- return &MockClonedRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)}
+func (_e *MockStagedRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Create_Call {
+ return &MockStagedRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)}
}
-func (_c *MockClonedRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Create_Call {
+func (_c *MockStagedRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Create_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
-func (_c *MockClonedRepository_Create_Call) Return(_a0 error) *MockClonedRepository_Create_Call {
+func (_c *MockStagedRepository_Create_Call) Return(_a0 error) *MockStagedRepository_Create_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Create_Call {
+func (_c *MockStagedRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Create_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, path, ref, message
-func (_m *MockClonedRepository) Delete(ctx context.Context, path string, ref string, message string) error {
+func (_m *MockStagedRepository) Delete(ctx context.Context, path string, ref string, message string) error {
ret := _m.Called(ctx, path, ref, message)
if len(ret) == 0 {
@@ -139,8 +139,8 @@ func (_m *MockClonedRepository) Delete(ctx context.Context, path string, ref str
return r0
}
-// MockClonedRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
-type MockClonedRepository_Delete_Call struct {
+// MockStagedRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
+type MockStagedRepository_Delete_Call struct {
*mock.Call
}
@@ -149,38 +149,38 @@ type MockClonedRepository_Delete_Call struct {
// - path string
// - ref string
// - message string
-func (_e *MockClonedRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockClonedRepository_Delete_Call {
- return &MockClonedRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)}
+func (_e *MockStagedRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockStagedRepository_Delete_Call {
+ return &MockStagedRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)}
}
-func (_c *MockClonedRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockClonedRepository_Delete_Call {
+func (_c *MockStagedRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockStagedRepository_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
-func (_c *MockClonedRepository_Delete_Call) Return(_a0 error) *MockClonedRepository_Delete_Call {
+func (_c *MockStagedRepository_Delete_Call) Return(_a0 error) *MockStagedRepository_Delete_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockClonedRepository_Delete_Call {
+func (_c *MockStagedRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockStagedRepository_Delete_Call {
_c.Call.Return(run)
return _c
}
-// Push provides a mock function with given fields: ctx, opts
-func (_m *MockClonedRepository) Push(ctx context.Context, opts PushOptions) error {
- ret := _m.Called(ctx, opts)
+// Push provides a mock function with given fields: ctx
+func (_m *MockStagedRepository) Push(ctx context.Context) error {
+ ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for Push")
}
var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, PushOptions) error); ok {
- r0 = rf(ctx, opts)
+ if rf, ok := ret.Get(0).(func(context.Context) error); ok {
+ r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
@@ -188,37 +188,36 @@ func (_m *MockClonedRepository) Push(ctx context.Context, opts PushOptions) erro
return r0
}
-// MockClonedRepository_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
-type MockClonedRepository_Push_Call struct {
+// MockStagedRepository_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
+type MockStagedRepository_Push_Call struct {
*mock.Call
}
// Push is a helper method to define mock.On call
// - ctx context.Context
-// - opts PushOptions
-func (_e *MockClonedRepository_Expecter) Push(ctx interface{}, opts interface{}) *MockClonedRepository_Push_Call {
- return &MockClonedRepository_Push_Call{Call: _e.mock.On("Push", ctx, opts)}
+func (_e *MockStagedRepository_Expecter) Push(ctx interface{}) *MockStagedRepository_Push_Call {
+ return &MockStagedRepository_Push_Call{Call: _e.mock.On("Push", ctx)}
}
-func (_c *MockClonedRepository_Push_Call) Run(run func(ctx context.Context, opts PushOptions)) *MockClonedRepository_Push_Call {
+func (_c *MockStagedRepository_Push_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Push_Call {
_c.Call.Run(func(args mock.Arguments) {
- run(args[0].(context.Context), args[1].(PushOptions))
+ run(args[0].(context.Context))
})
return _c
}
-func (_c *MockClonedRepository_Push_Call) Return(_a0 error) *MockClonedRepository_Push_Call {
+func (_c *MockStagedRepository_Push_Call) Return(_a0 error) *MockStagedRepository_Push_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Push_Call) RunAndReturn(run func(context.Context, PushOptions) error) *MockClonedRepository_Push_Call {
+func (_c *MockStagedRepository_Push_Call) RunAndReturn(run func(context.Context) error) *MockStagedRepository_Push_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function with given fields: ctx, path, ref
-func (_m *MockClonedRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
+func (_m *MockStagedRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
ret := _m.Called(ctx, path, ref)
if len(ret) == 0 {
@@ -247,8 +246,8 @@ func (_m *MockClonedRepository) Read(ctx context.Context, path string, ref strin
return r0, r1
}
-// MockClonedRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
-type MockClonedRepository_Read_Call struct {
+// MockStagedRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
+type MockStagedRepository_Read_Call struct {
*mock.Call
}
@@ -256,29 +255,29 @@ type MockClonedRepository_Read_Call struct {
// - ctx context.Context
// - path string
// - ref string
-func (_e *MockClonedRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockClonedRepository_Read_Call {
- return &MockClonedRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)}
+func (_e *MockStagedRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockStagedRepository_Read_Call {
+ return &MockStagedRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)}
}
-func (_c *MockClonedRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockClonedRepository_Read_Call {
+func (_c *MockStagedRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockStagedRepository_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
-func (_c *MockClonedRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockClonedRepository_Read_Call {
+func (_c *MockStagedRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockStagedRepository_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockClonedRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockClonedRepository_Read_Call {
+func (_c *MockStagedRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockStagedRepository_Read_Call {
_c.Call.Return(run)
return _c
}
// ReadTree provides a mock function with given fields: ctx, ref
-func (_m *MockClonedRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+func (_m *MockStagedRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
ret := _m.Called(ctx, ref)
if len(ret) == 0 {
@@ -307,37 +306,37 @@ func (_m *MockClonedRepository) ReadTree(ctx context.Context, ref string) ([]Fil
return r0, r1
}
-// MockClonedRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree'
-type MockClonedRepository_ReadTree_Call struct {
+// MockStagedRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree'
+type MockStagedRepository_ReadTree_Call struct {
*mock.Call
}
// ReadTree is a helper method to define mock.On call
// - ctx context.Context
// - ref string
-func (_e *MockClonedRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockClonedRepository_ReadTree_Call {
- return &MockClonedRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)}
+func (_e *MockStagedRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockStagedRepository_ReadTree_Call {
+ return &MockStagedRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)}
}
-func (_c *MockClonedRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockClonedRepository_ReadTree_Call {
+func (_c *MockStagedRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockStagedRepository_ReadTree_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
-func (_c *MockClonedRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockClonedRepository_ReadTree_Call {
+func (_c *MockStagedRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockStagedRepository_ReadTree_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockClonedRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockClonedRepository_ReadTree_Call {
+func (_c *MockStagedRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockStagedRepository_ReadTree_Call {
_c.Call.Return(run)
return _c
}
// Remove provides a mock function with given fields: ctx
-func (_m *MockClonedRepository) Remove(ctx context.Context) error {
+func (_m *MockStagedRepository) Remove(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
@@ -354,36 +353,36 @@ func (_m *MockClonedRepository) Remove(ctx context.Context) error {
return r0
}
-// MockClonedRepository_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
-type MockClonedRepository_Remove_Call struct {
+// MockStagedRepository_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
+type MockStagedRepository_Remove_Call struct {
*mock.Call
}
// Remove is a helper method to define mock.On call
// - ctx context.Context
-func (_e *MockClonedRepository_Expecter) Remove(ctx interface{}) *MockClonedRepository_Remove_Call {
- return &MockClonedRepository_Remove_Call{Call: _e.mock.On("Remove", ctx)}
+func (_e *MockStagedRepository_Expecter) Remove(ctx interface{}) *MockStagedRepository_Remove_Call {
+ return &MockStagedRepository_Remove_Call{Call: _e.mock.On("Remove", ctx)}
}
-func (_c *MockClonedRepository_Remove_Call) Run(run func(ctx context.Context)) *MockClonedRepository_Remove_Call {
+func (_c *MockStagedRepository_Remove_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Remove_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
-func (_c *MockClonedRepository_Remove_Call) Return(_a0 error) *MockClonedRepository_Remove_Call {
+func (_c *MockStagedRepository_Remove_Call) Return(_a0 error) *MockStagedRepository_Remove_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Remove_Call) RunAndReturn(run func(context.Context) error) *MockClonedRepository_Remove_Call {
+func (_c *MockStagedRepository_Remove_Call) RunAndReturn(run func(context.Context) error) *MockStagedRepository_Remove_Call {
_c.Call.Return(run)
return _c
}
// Test provides a mock function with given fields: ctx
-func (_m *MockClonedRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) {
+func (_m *MockStagedRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
@@ -412,36 +411,36 @@ func (_m *MockClonedRepository) Test(ctx context.Context) (*v0alpha1.TestResults
return r0, r1
}
-// MockClonedRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test'
-type MockClonedRepository_Test_Call struct {
+// MockStagedRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test'
+type MockStagedRepository_Test_Call struct {
*mock.Call
}
// Test is a helper method to define mock.On call
// - ctx context.Context
-func (_e *MockClonedRepository_Expecter) Test(ctx interface{}) *MockClonedRepository_Test_Call {
- return &MockClonedRepository_Test_Call{Call: _e.mock.On("Test", ctx)}
+func (_e *MockStagedRepository_Expecter) Test(ctx interface{}) *MockStagedRepository_Test_Call {
+ return &MockStagedRepository_Test_Call{Call: _e.mock.On("Test", ctx)}
}
-func (_c *MockClonedRepository_Test_Call) Run(run func(ctx context.Context)) *MockClonedRepository_Test_Call {
+func (_c *MockStagedRepository_Test_Call) Run(run func(ctx context.Context)) *MockStagedRepository_Test_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
-func (_c *MockClonedRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockClonedRepository_Test_Call {
+func (_c *MockStagedRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockStagedRepository_Test_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *MockClonedRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockClonedRepository_Test_Call {
+func (_c *MockStagedRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockStagedRepository_Test_Call {
_c.Call.Return(run)
return _c
}
// Update provides a mock function with given fields: ctx, path, ref, data, message
-func (_m *MockClonedRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
+func (_m *MockStagedRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -458,8 +457,8 @@ func (_m *MockClonedRepository) Update(ctx context.Context, path string, ref str
return r0
}
-// MockClonedRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
-type MockClonedRepository_Update_Call struct {
+// MockStagedRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
+type MockStagedRepository_Update_Call struct {
*mock.Call
}
@@ -469,29 +468,29 @@ type MockClonedRepository_Update_Call struct {
// - ref string
// - data []byte
// - message string
-func (_e *MockClonedRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Update_Call {
- return &MockClonedRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)}
+func (_e *MockStagedRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Update_Call {
+ return &MockStagedRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)}
}
-func (_c *MockClonedRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Update_Call {
+func (_c *MockStagedRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Update_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
-func (_c *MockClonedRepository_Update_Call) Return(_a0 error) *MockClonedRepository_Update_Call {
+func (_c *MockStagedRepository_Update_Call) Return(_a0 error) *MockStagedRepository_Update_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Update_Call {
+func (_c *MockStagedRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Update_Call {
_c.Call.Return(run)
return _c
}
// Validate provides a mock function with no fields
-func (_m *MockClonedRepository) Validate() field.ErrorList {
+func (_m *MockStagedRepository) Validate() field.ErrorList {
ret := _m.Called()
if len(ret) == 0 {
@@ -510,35 +509,35 @@ func (_m *MockClonedRepository) Validate() field.ErrorList {
return r0
}
-// MockClonedRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
-type MockClonedRepository_Validate_Call struct {
+// MockStagedRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate'
+type MockStagedRepository_Validate_Call struct {
*mock.Call
}
// Validate is a helper method to define mock.On call
-func (_e *MockClonedRepository_Expecter) Validate() *MockClonedRepository_Validate_Call {
- return &MockClonedRepository_Validate_Call{Call: _e.mock.On("Validate")}
+func (_e *MockStagedRepository_Expecter) Validate() *MockStagedRepository_Validate_Call {
+ return &MockStagedRepository_Validate_Call{Call: _e.mock.On("Validate")}
}
-func (_c *MockClonedRepository_Validate_Call) Run(run func()) *MockClonedRepository_Validate_Call {
+func (_c *MockStagedRepository_Validate_Call) Run(run func()) *MockStagedRepository_Validate_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
-func (_c *MockClonedRepository_Validate_Call) Return(_a0 field.ErrorList) *MockClonedRepository_Validate_Call {
+func (_c *MockStagedRepository_Validate_Call) Return(_a0 field.ErrorList) *MockStagedRepository_Validate_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockClonedRepository_Validate_Call {
+func (_c *MockStagedRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockStagedRepository_Validate_Call {
_c.Call.Return(run)
return _c
}
// Write provides a mock function with given fields: ctx, path, ref, data, message
-func (_m *MockClonedRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
+func (_m *MockStagedRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
ret := _m.Called(ctx, path, ref, data, message)
if len(ret) == 0 {
@@ -555,8 +554,8 @@ func (_m *MockClonedRepository) Write(ctx context.Context, path string, ref stri
return r0
}
-// MockClonedRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
-type MockClonedRepository_Write_Call struct {
+// MockStagedRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
+type MockStagedRepository_Write_Call struct {
*mock.Call
}
@@ -566,34 +565,34 @@ type MockClonedRepository_Write_Call struct {
// - ref string
// - data []byte
// - message string
-func (_e *MockClonedRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockClonedRepository_Write_Call {
- return &MockClonedRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)}
+func (_e *MockStagedRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockStagedRepository_Write_Call {
+ return &MockStagedRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)}
}
-func (_c *MockClonedRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockClonedRepository_Write_Call {
+func (_c *MockStagedRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockStagedRepository_Write_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string))
})
return _c
}
-func (_c *MockClonedRepository_Write_Call) Return(_a0 error) *MockClonedRepository_Write_Call {
+func (_c *MockStagedRepository_Write_Call) Return(_a0 error) *MockStagedRepository_Write_Call {
_c.Call.Return(_a0)
return _c
}
-func (_c *MockClonedRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockClonedRepository_Write_Call {
+func (_c *MockStagedRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockStagedRepository_Write_Call {
_c.Call.Return(run)
return _c
}
-// NewMockClonedRepository creates a new instance of MockClonedRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// NewMockStagedRepository creates a new instance of MockStagedRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
-func NewMockClonedRepository(t interface {
+func NewMockStagedRepository(t interface {
mock.TestingT
Cleanup(func())
-}) *MockClonedRepository {
- mock := &MockClonedRepository{}
+}) *MockStagedRepository {
+ mock := &MockStagedRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
diff --git a/pkg/registry/apis/provisioning/repository/staged_test.go b/pkg/registry/apis/provisioning/repository/staged_test.go
new file mode 100644
index 00000000000..3efe8ebbd5e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/staged_test.go
@@ -0,0 +1,144 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type mockStagedRepo struct {
+ *MockStageableRepository
+ *MockStagedRepository
+}
+
+func Test_WrapWithStageAndPushIfPossible_NonStageableRepository(t *testing.T) {
+ nonStageable := NewMockRepository(t)
+ var called bool
+ fn := func(repo Repository, staged bool) error {
+ called = true
+ return errors.New("operation failed")
+ }
+
+ err := WrapWithStageAndPushIfPossible(context.Background(), nonStageable, StageOptions{}, fn)
+ require.EqualError(t, err, "operation failed")
+ require.True(t, called)
+}
+
+func TestWrapWithStageAndPushIfPossible(t *testing.T) {
+ tests := []struct {
+ name string
+ setupMocks func(t *testing.T) *mockStagedRepo
+ operation func(repo Repository, staged bool) error
+ expectedError string
+ }{
+ {
+ name: "successful stage, operation, and push",
+ setupMocks: func(t *testing.T) *mockStagedRepo {
+ mockRepo := NewMockStageableRepository(t)
+ mockStaged := NewMockStagedRepository(t)
+
+ mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
+ mockStaged.EXPECT().Push(mock.Anything).Return(nil)
+ mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
+ return &mockStagedRepo{
+ MockStageableRepository: mockRepo,
+ MockStagedRepository: mockStaged,
+ }
+ },
+ operation: func(repo Repository, staged bool) error {
+ require.True(t, staged)
+ return nil
+ },
+ },
+ {
+ name: "stage failure",
+ setupMocks: func(t *testing.T) *mockStagedRepo {
+ mockRepo := NewMockStageableRepository(t)
+
+ mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(nil, errors.New("stage failed"))
+
+ return &mockStagedRepo{
+ MockStageableRepository: mockRepo,
+ }
+ },
+ operation: func(repo Repository, staged bool) error {
+ return nil
+ },
+ expectedError: "stage repository: stage failed",
+ },
+ {
+ name: "operation failure",
+ setupMocks: func(t *testing.T) *mockStagedRepo {
+ mockRepo := NewMockStageableRepository(t)
+ mockStaged := NewMockStagedRepository(t)
+
+ mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
+ mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
+
+ return &mockStagedRepo{
+ MockStageableRepository: mockRepo,
+ MockStagedRepository: mockStaged,
+ }
+ },
+ operation: func(repo Repository, staged bool) error {
+ return errors.New("operation failed")
+ },
+ expectedError: "operation failed",
+ },
+ {
+ name: "push failure",
+ setupMocks: func(t *testing.T) *mockStagedRepo {
+ mockRepo := NewMockStageableRepository(t)
+ mockStaged := NewMockStagedRepository(t)
+
+ mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
+ mockStaged.EXPECT().Push(mock.Anything).Return(errors.New("push failed"))
+ mockStaged.EXPECT().Remove(mock.Anything).Return(nil)
+
+ return &mockStagedRepo{
+ MockStageableRepository: mockRepo,
+ MockStagedRepository: mockStaged,
+ }
+ },
+ operation: func(repo Repository, staged bool) error {
+ return nil
+ },
+ expectedError: "wrapped push error: push failed",
+ },
+ {
+ name: "remove failure should only log",
+ setupMocks: func(t *testing.T) *mockStagedRepo {
+ mockRepo := NewMockStageableRepository(t)
+ mockStaged := NewMockStagedRepository(t)
+
+ mockRepo.EXPECT().Stage(mock.Anything, StageOptions{}).Return(mockStaged, nil)
+ mockStaged.EXPECT().Push(mock.Anything).Return(nil)
+ mockStaged.EXPECT().Remove(mock.Anything).Return(errors.New("remove failed"))
+
+ return &mockStagedRepo{
+ MockStageableRepository: mockRepo,
+ MockStagedRepository: mockStaged,
+ }
+ },
+ operation: func(repo Repository, staged bool) error {
+ return nil
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := tt.setupMocks(t)
+ err := WrapWithStageAndPushIfPossible(context.Background(), repo, StageOptions{}, tt.operation)
+
+ if tt.expectedError != "" {
+ require.EqualError(t, err, tt.expectedError)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/repository/test.go b/pkg/registry/apis/provisioning/repository/test.go
index 0e5778f76b3..f8b72d5b2af 100644
--- a/pkg/registry/apis/provisioning/repository/test.go
+++ b/pkg/registry/apis/provisioning/repository/test.go
@@ -96,7 +96,7 @@ func ValidateRepository(repo Repository) field.ErrorList {
return list
}
-func fromFieldError(err *field.Error) *provisioning.TestResults {
+func FromFieldError(err *field.Error) *provisioning.TestResults {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
diff --git a/pkg/registry/apis/provisioning/repository/test_test.go b/pkg/registry/apis/provisioning/repository/test_test.go
index 3121bcc4110..9168d529ea0 100644
--- a/pkg/registry/apis/provisioning/repository/test_test.go
+++ b/pkg/registry/apis/provisioning/repository/test_test.go
@@ -431,7 +431,7 @@ func TestFromFieldError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := fromFieldError(tt.fieldError)
+ result := FromFieldError(tt.fieldError)
require.NotNil(t, result)
require.Equal(t, tt.expectedCode, result.Code)
diff --git a/pkg/registry/apis/provisioning/repository/versioned_mock.go b/pkg/registry/apis/provisioning/repository/versioned_mock.go
index ab88470699e..cdd59b288e1 100644
--- a/pkg/registry/apis/provisioning/repository/versioned_mock.go
+++ b/pkg/registry/apis/provisioning/repository/versioned_mock.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.53.4. DO NOT EDIT.
+// Code generated by mockery v2.52.4. DO NOT EDIT.
package repository
diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go
index 13e9d861113..b1f5cef53c2 100644
--- a/pkg/registry/apis/provisioning/resources/resources.go
+++ b/pkg/registry/apis/provisioning/resources/resources.go
@@ -122,7 +122,7 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
err = r.repo.Write(ctx, fileName, options.Ref, body, commitMessage)
if err != nil {
- return "", fmt.Errorf("failed to write file: %w", err)
+ return "", fmt.Errorf("failed to write file: %s, %w", fileName, err)
}
return fileName, nil
diff --git a/pkg/registry/apis/provisioning/webhooks/register.go b/pkg/registry/apis/provisioning/webhooks/register.go
index ff73ad525d4..11a3d9a5a35 100644
--- a/pkg/registry/apis/provisioning/webhooks/register.go
+++ b/pkg/registry/apis/provisioning/webhooks/register.go
@@ -11,9 +11,8 @@ import (
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
- gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
@@ -180,42 +179,41 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
gvr.Resource,
r.GetName(),
)
- cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
- return gogit.Clone(ctx, e.clonedir, r, opts, e.secrets)
- }
-
- apiRepo, err := repository.NewGitHub(ctx, r, e.ghFactory, e.secrets, cloneFn)
- if err != nil {
- return nil, fmt.Errorf("create github API repository: %w", err)
- }
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
- if !e.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) {
- logger.Debug("Instantiating Github repository with go-git and Github API")
- return NewGithubWebhookRepository(apiRepo, webhookURL, e.secrets), nil
- }
-
- logger.Info("Instantiating Github repository with nanogit")
-
+ logger.Info("Instantiating Github repository with webhooks")
ghCfg := r.Spec.GitHub
if ghCfg == nil {
return nil, fmt.Errorf("github configuration is required for nano git")
}
- gitCfg := nanogit.RepositoryConfig{
+ // Decrypt GitHub token if needed
+ ghToken := ghCfg.Token
+ if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
+ decrypted, err := e.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt github token: %w", err)
+ }
+ ghToken = string(decrypted)
+ }
+
+ gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
- Token: ghCfg.Token,
+ Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
}
- nanogitRepo, err := nanogit.NewGitRepository(ctx, e.secrets, r, gitCfg)
+ gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
- return nil, fmt.Errorf("error creating nanogit repository: %w", err)
+ return nil, fmt.Errorf("error creating git repository: %w", err)
}
- basicRepo := nanogit.NewGithubRepository(apiRepo, nanogitRepo)
+ basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken)
+ if err != nil {
+ return nil, fmt.Errorf("error creating github repository: %w", err)
+ }
return NewGithubWebhookRepository(basicRepo, webhookURL, e.secrets), nil
}
diff --git a/pkg/registry/apis/provisioning/webhooks/repository.go b/pkg/registry/apis/provisioning/webhooks/repository.go
index c9a619e6c7e..451863fceb8 100644
--- a/pkg/registry/apis/provisioning/webhooks/repository.go
+++ b/pkg/registry/apis/provisioning/webhooks/repository.go
@@ -25,14 +25,14 @@ type WebhookRepository interface {
}
type GithubWebhookRepository interface {
- repository.GithubRepository
+ pgh.GithubRepository
repository.Hooks
WebhookRepository
}
type githubWebhookRepository struct {
- repository.GithubRepository
+ pgh.GithubRepository
config *provisioning.Repository
owner string
repo string
@@ -42,7 +42,7 @@ type githubWebhookRepository struct {
}
func NewGithubWebhookRepository(
- basic repository.GithubRepository,
+ basic pgh.GithubRepository,
webhookURL string,
secrets secrets.Service,
) GithubWebhookRepository {
diff --git a/pkg/registry/apis/provisioning/webhooks/repository_test.go b/pkg/registry/apis/provisioning/webhooks/repository_test.go
index 6c5f55bc35f..1b0eb4b1122 100644
--- a/pkg/registry/apis/provisioning/webhooks/repository_test.go
+++ b/pkg/registry/apis/provisioning/webhooks/repository_test.go
@@ -15,7 +15,7 @@ import (
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -983,14 +983,14 @@ func TestGitHubRepository_Webhook(t *testing.T) {
func TestGitHubRepository_CommentPullRequest(t *testing.T) {
tests := []struct {
name string
- setupMock func(m *pgh.MockClient)
+ setupMock func(m *github.MockClient)
prNumber int
comment string
expectedError error
}{
{
name: "successfully comment on pull request",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
m.On("CreatePullRequestComment", mock.Anything, "grafana", "grafana", 123, "Test comment").
Return(nil)
},
@@ -1000,7 +1000,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
},
{
name: "error commenting on pull request",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
m.On("CreatePullRequestComment", mock.Anything, "grafana", "grafana", 456, "Error comment").
Return(fmt.Errorf("failed to create comment"))
},
@@ -1013,7 +1013,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
+ mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1050,7 +1050,7 @@ func TestGitHubRepository_CommentPullRequest(t *testing.T) {
func TestGitHubRepository_OnCreate(t *testing.T) {
tests := []struct {
name string
- setupMock func(m *pgh.MockClient)
+ setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedHook *provisioning.WebhookStatus
@@ -1058,12 +1058,12 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
}{
{
name: "successfully create webhook",
- setupMock: func(m *pgh.MockClient) {
- m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(cfg pgh.WebhookConfig) bool {
+ setupMock: func(m *github.MockClient) {
+ m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(cfg github.WebhookConfig) bool {
return cfg.URL == "https://example.com/webhook" &&
cfg.ContentType == "json" &&
cfg.Active == true
- })).Return(pgh.WebhookConfig{
+ })).Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Secret: "test-secret",
@@ -1086,7 +1086,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
},
{
name: "no webhook URL",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// No webhook creation expected
},
config: &provisioning.Repository{
@@ -1102,9 +1102,9 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
},
{
name: "error creating webhook",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
- Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
+ Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1122,7 +1122,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
+ mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1166,7 +1166,7 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
func TestGitHubRepository_OnUpdate(t *testing.T) {
tests := []struct {
name string
- setupMock func(m *pgh.MockClient)
+ setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedHook *provisioning.WebhookStatus
@@ -1174,17 +1174,17 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
}{
{
name: "successfully update webhook when webhook exists",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{
+ Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: []string{"push"},
}, nil)
// Mock editing the webhook
- m.On("EditWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
+ m.On("EditWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.ID == 123 && hook.URL == "https://example.com/webhook-updated" &&
slices.Equal(hook.Events, subscribedEvents)
})).Return(nil)
@@ -1212,18 +1212,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when it doesn't exist",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
+ Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock creating a new webhook
- m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
+ m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
- })).Return(pgh.WebhookConfig{
+ })).Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1252,7 +1252,7 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "no webhook URL provided",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{},
@@ -1262,9 +1262,9 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error getting webhook",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{}, fmt.Errorf("failed to get webhook"))
+ Return(github.WebhookConfig{}, fmt.Errorf("failed to get webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1285,10 +1285,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error editing webhook",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{
+ Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: []string{"push"},
@@ -1317,10 +1317,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when webhook status is nil",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
- Return(pgh.WebhookConfig{
+ Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1348,10 +1348,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "create webhook when webhook ID is zero",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock creating a new webhook
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
- Return(pgh.WebhookConfig{
+ Return(github.WebhookConfig{
ID: 789,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1382,10 +1382,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error when creating webhook fails",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock webhook creation failure
m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.Anything).
- Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
+ Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1403,18 +1403,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "creates webhook when ErrResourceNotFound",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
+ Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock creating a new webhook
- m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
+ m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
- })).Return(pgh.WebhookConfig{
+ })).Return(github.WebhookConfig{
ID: 456,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1443,18 +1443,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "error on create when not found",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock webhook not found
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{}, pgh.ErrResourceNotFound)
+ Return(github.WebhookConfig{}, github.ErrResourceNotFound)
// Mock error when creating a new webhook
- m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook pgh.WebhookConfig) bool {
+ m.On("CreateWebhook", mock.Anything, "grafana", "grafana", mock.MatchedBy(func(hook github.WebhookConfig) bool {
return hook.URL == "https://example.com/webhook" &&
hook.ContentType == "json" &&
slices.Equal(hook.Events, subscribedEvents) &&
hook.Active == true
- })).Return(pgh.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
+ })).Return(github.WebhookConfig{}, fmt.Errorf("failed to create webhook"))
},
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
@@ -1475,10 +1475,10 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
{
name: "no update needed when URL and events match",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock getting the existing webhook with matching URL and events
m.On("GetWebhook", mock.Anything, "grafana", "grafana", int64(123)).
- Return(pgh.WebhookConfig{
+ Return(github.WebhookConfig{
ID: 123,
URL: "https://example.com/webhook",
Events: subscribedEvents,
@@ -1514,7 +1514,7 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
+ mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
@@ -1563,14 +1563,14 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
- setupMock func(m *pgh.MockClient)
+ setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedError error
}{
{
name: "successfully delete webhook",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock deleting the webhook
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(nil)
@@ -1593,7 +1593,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "no webhook URL provided",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{},
@@ -1602,7 +1602,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "webhook not found in status",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// No mocks needed
},
config: &provisioning.Repository{
@@ -1620,7 +1620,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "error deleting webhook",
- setupMock: func(m *pgh.MockClient) {
+ setupMock: func(m *github.MockClient) {
// Mock webhook deletion failure
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(fmt.Errorf("failed to delete webhook"))
@@ -1646,7 +1646,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
- mockGH := pgh.NewMockClient(t)
+ mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
// Create repository with mock
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 7feb6c5dbda..c6a388c9660 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -337,13 +337,6 @@ var (
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
- {
- Name: "nanoGit",
- Description: "Use experimental git library for provisioning",
- Stage: FeatureStageExperimental,
- RequiresRestart: true,
- Owner: grafanaAppPlatformSquad,
- },
{
Name: "grafanaAPIServerEnsureKubectlAccess",
Description: "Start an additional https handler and write kubectl options",
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 0fe72290ac6..d071dbd8d91 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -43,7 +43,6 @@ mlExpressions,experimental,@grafana/alerting-squad,false,false,false
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
-nanoGit,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false
featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false
awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 981a2ad0db2..9fbc4ee06fb 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -183,10 +183,6 @@ const (
// Next generation provisioning... and git
FlagProvisioning = "provisioning"
- // FlagNanoGit
- // Use experimental git library for provisioning
- FlagNanoGit = "nanoGit"
-
// FlagGrafanaAPIServerEnsureKubectlAccess
// Start an additional https handler and write kubectl options
FlagGrafanaAPIServerEnsureKubectlAccess = "grafanaAPIServerEnsureKubectlAccess"
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index 4cb7929a9c9..e63024c3a52 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -2049,7 +2049,8 @@
"metadata": {
"name": "nanoGit",
"resourceVersion": "1750434297879",
- "creationTimestamp": "2025-06-17T17:07:30Z"
+ "creationTimestamp": "2025-06-17T17:07:30Z",
+ "deletionTimestamp": "2025-07-09T11:53:17Z"
},
"spec": {
"description": "Use experimental git library for provisioning",
diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go
index bf63e2358fc..24ffe18b0e2 100644
--- a/pkg/tests/apis/provisioning/helper_test.go
+++ b/pkg/tests/apis/provisioning/helper_test.go
@@ -2,10 +2,7 @@ package provisioning
import (
"context"
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
- "net/http"
"os"
"path"
"strings"
@@ -13,7 +10,6 @@ import (
"text/template"
"time"
- gh "github.com/google/go-github/v70/github"
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -224,20 +220,8 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
}
helper := apis.NewK8sTestHelper(t, opts)
- helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient(
- ghmock.WithRequestMatchHandler(ghmock.GetUser, ghAlwaysWrite(t, &gh.User{})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})),
- ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposByOwnerByRepo, ghAlwaysWrite(t, &gh.Repository{})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposBranchesByOwnerByRepoByBranch, ghAlwaysWrite(t, &gh.Branch{})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha, ghAlwaysWrite(t, &gh.Tree{})),
- ghmock.WithRequestMatchHandler(
- ghmock.DeleteReposHooksByOwnerByRepoByHookId,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- }),
- ),
- )
+ // FIXME: keeping this line here to keep the dependency around until we have tests which use this again.
+ helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient()
repositories := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
@@ -310,33 +294,6 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
}
}
-func ghAlwaysWrite(t *testing.T, body any) http.HandlerFunc {
- marshalled := ghmock.MustMarshal(body)
- return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, err := w.Write(marshalled)
- require.NoError(t, err, "failed to write body in mock")
- })
-}
-
-func ghHandleTree(t *testing.T, refs map[string][]*gh.TreeEntry) http.HandlerFunc {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sha := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
- require.NotEmpty(t, sha, "sha path parameter was missing?")
-
- entries := refs[sha]
- require.NotNil(t, entries, "no entries for sha %s", sha)
-
- tree := &gh.Tree{
- SHA: gh.Ptr(sha),
- Truncated: gh.Ptr(false),
- Entries: entries,
- }
-
- _, err := w.Write(ghmock.MustMarshal(tree))
- require.NoError(t, err, "failed to write body in mock")
- })
-}
-
func mustNestedString(obj map[string]interface{}, fields ...string) string {
v, _, err := unstructured.NestedString(obj, fields...)
if err != nil {
@@ -350,15 +307,6 @@ func asJSON(obj any) []byte {
return jj
}
-func treeEntryDir(dirName string, sha string) *gh.TreeEntry {
- return &gh.TreeEntry{
- SHA: gh.Ptr(sha),
- Path: gh.Ptr(dirName),
- Type: gh.Ptr("tree"),
- Mode: gh.Ptr("040000"),
- }
-}
-
func unstructuredToRepository(t *testing.T, obj *unstructured.Unstructured) *provisioning.Repository {
bytes, err := obj.MarshalJSON()
require.NoError(t, err)
@@ -369,33 +317,3 @@ func unstructuredToRepository(t *testing.T, obj *unstructured.Unstructured) *pro
return repo
}
-
-func treeEntry(fpath string, content []byte) *gh.TreeEntry {
- sha := sha256.Sum256(content)
-
- return &gh.TreeEntry{
- SHA: gh.Ptr(hex.EncodeToString(sha[:])),
- Path: gh.Ptr(fpath),
- Size: gh.Ptr(len(content)),
- Type: gh.Ptr("blob"),
- Mode: gh.Ptr("100644"),
- Content: gh.Ptr(string(content)),
- }
-}
-
-func repoContent(fpath string, content []byte) *gh.RepositoryContent {
- sha := sha256.Sum256(content)
- typ := "blob"
- if strings.HasSuffix(fpath, "/") {
- typ = "tree"
- }
-
- return &gh.RepositoryContent{
- SHA: gh.Ptr(hex.EncodeToString(sha[:])),
- Name: gh.Ptr(path.Base(fpath)),
- Path: &fpath,
- Size: gh.Ptr(len(content)),
- Type: &typ,
- Content: gh.Ptr(string(content)),
- }
-}
diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go
index a4a1a6dde11..27bc459e179 100644
--- a/pkg/tests/apis/provisioning/provisioning_test.go
+++ b/pkg/tests/apis/provisioning/provisioning_test.go
@@ -7,13 +7,10 @@ import (
"net/http"
"os"
"path/filepath"
- "regexp"
"strings"
"testing"
"time"
- gh "github.com/google/go-github/v70/github"
- ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -174,7 +171,7 @@ func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) {
}, time.Second*10, time.Millisecond*10, "Expected to be able to start a sync job")
require.EventuallyWithT(t, func(collect *assert.CollectT) {
- //helper.TriggerJobProcessing(t)
+ // helper.TriggerJobProcessing(t)
result, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{},
"jobs", string(jobObj.GetUID()))
@@ -212,50 +209,25 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
helper := runGrafana(t)
ctx := context.Background()
- helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient(
- ghmock.WithRequestMatchHandler(ghmock.GetUser, ghAlwaysWrite(t, &gh.User{Name: gh.Ptr("github-user")})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})),
- ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})),
- ghmock.WithRequestMatchHandler(ghmock.GetReposByOwnerByRepo, ghAlwaysWrite(t, &gh.Repository{ID: gh.Ptr(int64(234))})),
- ghmock.WithRequestMatchHandler(
- ghmock.GetReposBranchesByOwnerByRepoByBranch,
- ghAlwaysWrite(t, &gh.Branch{
- Name: gh.Ptr("main"),
- Commit: &gh.RepositoryCommit{SHA: gh.Ptr("deadbeef")},
- }),
- ),
- ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha,
- ghHandleTree(t, map[string][]*gh.TreeEntry{
- "deadbeef": {
- treeEntryDir("grafana", "subtree"),
- },
- "subtree": {
- treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")),
- treeEntryDir("subdir", "subtree2"),
- treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")),
- },
- })),
- ghmock.WithRequestMatchHandler(
- ghmock.GetReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- pathRegex := regexp.MustCompile(`/repos/[^/]+/[^/]+/contents/(.*)`)
- matches := pathRegex.FindStringSubmatch(r.URL.Path)
- require.NotNil(t, matches, "no match for contents?")
- path := matches[1]
+ // FIXME: instead of using an existing GitHub repository, we should create a new one for the tests and a branch
+ // This was the previous structure
+ // ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha,
+ // ghHandleTree(t, map[string][]*gh.TreeEntry{
+ // "deadbeef": {
+ // treeEntryDir("grafana", "subtree"),
+ // },
+ // "subtree": {
+ // treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")),
+ // treeEntryDir("subdir", "subtree2"),
+ // treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")),
+ // },
+ // })),
- var err error
- switch path {
- case "grafana/dashboard.json":
- _, err = w.Write(ghmock.MustMarshal(repoContent(path, helper.LoadFile("testdata/all-panels.json"))))
- case "grafana/subdir/dashboard2.yaml":
- _, err = w.Write(ghmock.MustMarshal(repoContent(path, helper.LoadFile("testdata/text-options.json"))))
- default:
- t.Fatalf("got unexpected path: %s", path)
- }
- require.NoError(t, err)
- }),
- ),
- )
+ // FIXME: uncomment these to implement webhook integration tests.
+ // helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient(
+ // ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})),
+ // ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})),
+ // )
const repo = "github-create-test"
_, err := helper.Repositories.Resource.Create(ctx,
@@ -280,8 +252,10 @@ func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) {
for _, v := range found.Items {
names = append(names, v.GetName())
}
- assert.Contains(t, names, "n1jR8vnnz", "should contain dashboard.json's contents")
- assert.Contains(t, names, "WZ7AhQiVz", "should contain dashboard2.yaml's contents")
+ require.Len(t, names, 3, "should have three dashboards")
+ assert.Contains(t, names, "adg5vbj", "should contain dashboard.json's contents")
+ assert.Contains(t, names, "admfz74", "should contain dashboard2.yaml's contents")
+ assert.Contains(t, names, "adn5mxb", "should contain dashboard2.yaml's contents")
err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{})
require.NoError(t, err, "should delete values")
diff --git a/pkg/tests/apis/provisioning/testdata/github-readonly.json.tmpl b/pkg/tests/apis/provisioning/testdata/github-readonly.json.tmpl
index 220b0936b0e..86489c7d65c 100644
--- a/pkg/tests/apis/provisioning/testdata/github-readonly.json.tmpl
+++ b/pkg/tests/apis/provisioning/testdata/github-readonly.json.tmpl
@@ -9,10 +9,10 @@
"description": "{{ or .Description .Name "Load grafana dashboard from fake repository" }}",
"type": "github",
"github": {
- "url": "{{ or .URL "https://github.com/grafana/git-ui-sync-demo" }}",
- "branch": "{{ or .Branch "dummy" }}",
+ "url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
+ "branch": "{{ or .Branch "integration-test" }}",
"generateDashboardPreviews": {{ if .GenerateDashboardPreviews }} true {{ else }} false {{ end }},
- "token": "{{ or .Token "github_pat_dummy" }}",
+ "token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
},
"sync": {