From 0e53749906cd57f919cc9d960d2a0a915f11aa96 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:38:17 +0000 Subject: [PATCH 01/33] I18n: Download translations from Crowdin (#107936) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/de-DE/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/es-ES/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/fr-FR/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/hu-HU/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/id-ID/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/it-IT/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/ja-JP/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/ko-KR/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/nl-NL/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/pl-PL/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/pt-BR/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/pt-PT/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/ru-RU/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/sv-SE/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/tr-TR/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/zh-Hans/grafana.json | 53 ++++++++++++++++++++++++----- public/locales/zh-Hant/grafana.json | 53 ++++++++++++++++++++++++----- 18 files changed, 810 insertions(+), 144 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 70d397d444a..913c87e01d0 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -516,6 +516,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Upravit YAML", @@ -1517,15 +1520,12 @@ "group-details": { "ds-features-error": "Chyba při načítání podrobností o zdroji dat", "edit": "Upravit", - "evaluations-to-fire": "Cykly hodnocení ke spuštění", "export": "Exportovat", "folder": "Složka", "group-loading-error": "Chyba při načítání skupiny", "interval": "Interval", "namespace": "Jmenný prostor", - "pending-period": "Období čekání", - "recording": "Probíhá záznam", - "rule-name": "Jméno pravidla" + "new": "" }, "group-edit": { "ds-error": "Chyba při načítání podrobností o zdroji dat", @@ -2255,6 +2255,9 @@ "label-export-all": "Exportovat vše" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Nelze načíst editor dotazů, důvod: {{errorMessage}}" }, @@ -2676,6 +2679,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Nenašli jste požadovaný zdroj dat? Některé zdroje dat nepodporují upozorňování. Další informace zobrazíte kliknutím na ikonu." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Přidat nového správce výstrah", "builtin-alertmanager": "Vestavěný správce výstrah", @@ -2911,6 +2919,11 @@ "timestamp": { "time-ago": "(před {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Cílová složka není prázdná, některá pravidla mohou být přepsána nebo odstraněna. Opravdu chcete importovat tato pravidla výstrah do pravidel spravovaných Grafanou?", @@ -5696,6 +5709,20 @@ "usage-count_many": "Použito na {{count}} nástěnkách", "usage-count_other": "Použito na {{count}} nástěnkách" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Ve vybrané složce už existuje nástěnka se stejným názvem. Chcete přesto tuto nástěnku uložit?", "title-name-already-exists": "Název již existuje" @@ -8026,6 +8053,9 @@ "filter-popup-match-case": "Rozlišovat malá a velká písmena", "inspect-drawer-title": "Zkontrolovat hodnotu", "inspect-menu-label": "Zkontrolovat hodnotu", + "nested-table": { + "no-data": "" + }, "no-values-label": "Žádné hodnoty", "pagination-summary": "{{itemsRangeStart}} – {{displayedEnd}} z {{numRows}} řádků" }, @@ -8770,6 +8800,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8782,6 +8813,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8884,6 +8916,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9142,6 +9179,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Nástěnku už zajišťuje a spravuje Grafana v cloudové instanci. Doporučujeme používat zajištěnou nástěnku. Pokud si přesto přejete zkopírovat nástěnku do cloudové instance, změňte ID nástěnky v JSON nástěnky, uložte nový snímek a nahrajte ji znovu.", "datasource-already-managed": "Zdroj dat už zajišťuje a spravuje Grafana v cloudové instanci. Pokud se jedná o jiný zdroj, nastavte jiné UID a zkuste to znovu.", "datasource-invalid-url": "Existuje zdroj dat s neplatnou adresou URL. Zadejte platnou adresu URL a zkuste to znovu.", @@ -10377,10 +10416,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e0995ddb557..d55bb859eda 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAML bearbeiten", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Fehler beim Laden der Datenquellendetails", "edit": "Bearbeiten", - "evaluations-to-fire": "Auszulösende Evaluierungszyklen", "export": "Exportieren", "folder": "Ordner", "group-loading-error": "Fehler beim Laden der Gruppe", "interval": "Intervall", "namespace": "Namensraum", - "pending-period": "Wartezeit", - "recording": "Aufzeichnung", - "rule-name": "Regelname" + "new": "" }, "group-edit": { "ds-error": "Fehler beim Laden der Datenquellendetails", @@ -2237,6 +2237,9 @@ "label-export-all": "Alles exportieren" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Der Abfrage-Editor konnte nicht geladen werden wegen: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Sie finden Ihre gewünschte Datenquelle nicht? Einige Datenquellen werden für Warnungen nicht unterstützt. Klicken Sie für weitere Informationen auf das Symbol." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Neuen Alertmanager hinzufügen", "builtin-alertmanager": "Integrierter Alertmanager", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(vor {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Der Zielordner ist nicht leer, einige Regeln können überschrieben oder entfernt werden. Sind Sie sicher, dass Sie diese Warnregeln zu von Grafana verwalteten Regeln importieren möchten?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Verwendet bei {{count}} Dashboards", "usage-count_other": "Verwendet bei {{count}} Dashboards" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Ein Dashboard mit demselben Namen im ausgewählten Ordner existiert bereits. Möchten Sie dieses Dashboard trotzdem speichern?", "title-name-already-exists": "Der Name existiert bereits" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Groß-/Kleinschreibung beachten", "inspect-drawer-title": "Wert prüfen", "inspect-menu-label": "Wert prüfen", + "nested-table": { + "no-data": "" + }, "no-values-label": "Keine Werte", "pagination-summary": "{{itemsRangeStart}}–{{displayedEnd}} von {{numRows}} Zeilen" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Das Dashboard wird bereits von Grafana in der Cloud-Instanz bereitgestellt und verwaltet. Wir empfehlen, in Zukunft das bereitgestellte Dashboard zu verwenden. Wenn Sie das Dashboard weiterhin in die Cloud-Instanz kopieren möchten, ändern Sie bitte die Dashboard-ID im Dashboard-JSON, speichern Sie einen neuen Snapshot und laden Sie ihn erneut hoch.", "datasource-already-managed": "Die Datenquelle wird bereits von Grafana in der Cloud-Instanz bereitgestellt und verwaltet. Wenn es sich dabei um eine andere Ressource handelt, legen Sie bitte eine andere UID fest und versuchen Sie es erneut.", "datasource-invalid-url": "Es gibt eine Datenquelle mit einer ungültigen URL. Geben Sie eine gültige URL an und versuchen Sie es erneut.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 2f6067d59ad..fb3b395d998 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Editar YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Error al cargar los detalles de la fuente de datos", "edit": "Editar", - "evaluations-to-fire": "Ciclos de evaluación para activar", "export": "Exportar", "folder": "Carpeta", "group-loading-error": "Error al cargar el grupo", "interval": "Intervalo", "namespace": "Nombre del espacio", - "pending-period": "Periodo pendiente", - "recording": "Registro", - "rule-name": "Nombre de la regla" + "new": "" }, "group-edit": { "ds-error": "Error al cargar los detalles de la fuente de datos", @@ -2237,6 +2237,9 @@ "label-export-all": "Exportar todo" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "No se ha podido cargar el editor de consultas debido a: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "¿No encuentras la fuente de datos que quieres? Algunas fuentes de datos no son compatibles con las alertas. Haz clic en el icono para obtener más información." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Añadir nuevo Alertmanager", "builtin-alertmanager": "Alertmanager integrado", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(hace {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "La carpeta de destino no está vacía y pueden sobrescribirse o eliminarse algunas reglas. ¿Seguro que quieres importar estas reglas de alerta a las reglas gestionadas por Grafana?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Utilizado en {{count}} dashboards", "usage-count_other": "Utilizado en {{count}} dashboards" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Ya existe un dashboard con el mismo nombre en la carpeta seleccionada. ¿Seguro que quieres guardar este dashboard?", "title-name-already-exists": "Este nombre ya existe" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Coincidir mayúsculas/minúsculas", "inspect-drawer-title": "Inspeccionar valor", "inspect-menu-label": "Inspeccionar valor", + "nested-table": { + "no-data": "" + }, "no-values-label": "Sin valores", "pagination-summary": "{{itemsRangeStart}}-{{displayedEnd}} de {{numRows}} filas" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "Aceptar", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "El panel de control ya está aprovisionado y gestionado por Grafana en la instancia en la nube. A partir de ahora, recomendamos utilizar el panel de control proporcionado. Si todavía quieres copiar el panel de control a la instancia en la nube, cambia el ID del panel de control en el JSON del panel de control, guarda una nueva instantánea y vuelve a subirla.", "datasource-already-managed": "La fuente de datos ya está aprovisionada y gestionada por Grafana en la instancia en la nube. Si se trata de un recurso distinto, configura otro UID e inténtalo de nuevo.", "datasource-invalid-url": "Hay una fuente de datos que tiene una URL no válida. Proporciona una URL válida e inténtalo de nuevo.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index db2ff959b42..46c4e18b668 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Modifier le fichier YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Erreur lors du chargement des détails de la source de données", "edit": "Modifier", - "evaluations-to-fire": "Cycles d’évaluation à déclencher", "export": "Exporter", "folder": "Dossier", "group-loading-error": "Erreur lors du chargement du groupe", "interval": "Intervalle", "namespace": "Namespace", - "pending-period": "Période en attente", - "recording": "Enregistrement", - "rule-name": "Nom de la règle" + "new": "" }, "group-edit": { "ds-error": "Erreur lors du chargement des détails de la source de données", @@ -2237,6 +2237,9 @@ "label-export-all": "Tout exporter" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Impossible de charger l’éditeur de requête en raison de : {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Vous ne trouvez pas la source de données que vous voulez ? Certaines sources de données ne sont pas prises en charge pour les alertes. Cliquez sur l’icône pour en savoir plus." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Ajouter un nouvel Alertmanager", "builtin-alertmanager": "Alertmanager intégré", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(il y a {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Le dossier cible n’est pas vide, certaines règles peuvent être remplacées ou supprimées. Voulez-vous vraiment importer ces règles d’alerte dans les règles gérées par Grafana ?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Utilisé sur {{count}} tableaux de bord", "usage-count_other": "Utilisé sur {{count}} tableaux de bord" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Un tableau de bord du même nom existe déjà dans le dossier sélectionné. Voulez-vous toujours enregistrer ce tableau de bord ?", "title-name-already-exists": "Nom déjà existant" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Respecter la casse", "inspect-drawer-title": "Inspecter la valeur", "inspect-menu-label": "Inspecter la valeur", + "nested-table": { + "no-data": "" + }, "no-values-label": "Aucune valeur", "pagination-summary": "{{itemsRangeStart}} – {{displayedEnd}} sur {{numRows}} lignes" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Le tableau de bord est déjà mis en service et géré par Grafana dans l'instance cloud. Nous vous recommandons d'utiliser le tableau de bord mis en service à l'avenir. Si vous souhaitez toujours copier le tableau de bord dans l'instance cloud, modifiez l'ID du tableau de bord dans le JSON du tableau de bord, puis enregistrez un nouvel instantané et téléchargez à nouveau.", "datasource-already-managed": "La source de données est déjà mise en service et gérée par Grafana dans l'instance cloud. S'il s'agit d'une ressource différente, définissez un autre UID et réessayez.", "datasource-invalid-url": "Il y a une source de données dont l'URL n'est pas valide. Fournissez une URL valide et réessayez.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 3339a1cb0ae..ffac048a310 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAML szerkesztése", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Hiba történt az adatforrás adatainak betöltésekor", "edit": "Szerkesztés", - "evaluations-to-fire": "Aktiválandó értékelési ciklusok", "export": "Exportálás", "folder": "Mappa", "group-loading-error": "Hiba a csoport betöltésekor", "interval": "Intervallum", "namespace": "Névtér", - "pending-period": "Függőben lévő időszak", - "recording": "Felvételkészítés", - "rule-name": "Szabálynév" + "new": "" }, "group-edit": { "ds-error": "Hiba történt az adatforrás adatainak betöltésekor", @@ -2237,6 +2237,9 @@ "label-export-all": "Összes exportálása" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Nem sikerült betölteni a lekérdezésszerkesztőt a következők miatt: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Nem találja a kívánt adatforrást? Egyes adatforrások nem támogatottak a riasztáshoz. További információért kattintson az ikonra." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Új riasztáskezelő hozzáadása", "builtin-alertmanager": "Beépített riasztáskezelő", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(ennyi ideje: {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "A célmappa nem üres, néhány szabály felül lesz írva vagy el lesz távolítva. Biztosan importálja ezeket a riasztási szabályokat a Grafana által felügyelt szabályokba?", @@ -5656,6 +5669,20 @@ "usage-count_one": "{{count}} irányítópulton használatos", "usage-count_other": "{{count}} irányítópulton használatos" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Egy irányítópult már létezik ugyanazzal a névvel a kijelölt mappában. Biztosan menti ezt az irányítópultot?", "title-name-already-exists": "A név már létezik" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Nagybetűérzékeny", "inspect-drawer-title": "Érték ellenőrzése", "inspect-menu-label": "Érték ellenőrzése", + "nested-table": { + "no-data": "" + }, "no-values-label": "Nincsenek értékek", "pagination-summary": "{{itemsRangeStart}} – {{displayedEnd}} / {{numRows}} sor" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Az irányítópultot a Grafana már kiépítette és felügyeli a felhőpéldányban. Javasoljuk, hogy a továbbiakban használja a kiépített irányítópultot. Ha továbbra is át szeretné másolni az irányítópultot a felhőpéldányba, akkor módosítsa az irányítópult azonosítóját az irányítópult JSON-fájljában, mentse az új pillanatképet, és töltse fel újra.", "datasource-already-managed": "Az adatforrást a Grafana már kiépítette és felügyeli a felhőpéldányban. Ha ez egy másik erőforrás, állítson be egy másik UID-t, és próbálkozzon újra.", "datasource-invalid-url": "Egy adatforrás érvénytelen URL-címmel rendelkezik. Adjon meg egy érvényes URL-címet, és próbálkozzon újra.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 534eb42f74d..4ff84a3c438 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -504,6 +504,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Edit YAML", @@ -1499,15 +1502,12 @@ "group-details": { "ds-features-error": "Kesalahan saat memuat sumber data", "edit": "Edit", - "evaluations-to-fire": "Siklus evaluasi yang akan dinyalakan", "export": "Ekspor", "folder": "Folder", "group-loading-error": "Kesalahan saat memuat grup", "interval": "Interval", "namespace": "Ruang nama", - "pending-period": "Periode tertunda", - "recording": "Pencatatan", - "rule-name": "Nama aturan" + "new": "" }, "group-edit": { "ds-error": "Kesalahan saat memuat sumber data", @@ -2228,6 +2228,9 @@ "label-export-all": "Ekspor semua" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Tidak dapat memuat editor kueri karena: {{errorMessage}}" }, @@ -2637,6 +2640,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Tidak menemukan sumber data yang Anda inginkan? Beberapa sumber data tidak didukung untuk peringatan. Klik ikon untuk informasi selengkapnya." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Tambah Alertmanager baru", "builtin-alertmanager": "Alertmanager bawaan", @@ -2872,6 +2880,11 @@ "timestamp": { "time-ago": "({{time}} lalu)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Folder target tidak kosong, beberapa aturan mungkin ditimpa atau dihapus. Anda yakin ingin mengimpor aturan peringatan ini ke aturan yang dikelola Grafana?", @@ -5636,6 +5649,20 @@ "last-edited": "{{timeAgo}} oleh", "usage-count_other": "Digunakan pada {{count}} dasbor" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Dasbor dengan nama yang sama di folder yang dipilih sudah ada. Ingin tetap menyimpan dasbor ini?", "title-name-already-exists": "Nama sudah ada" @@ -7963,6 +7990,9 @@ "filter-popup-match-case": "Cocok huruf besar kecil", "inspect-drawer-title": "Periksa nilai", "inspect-menu-label": "Periksa nilai", + "nested-table": { + "no-data": "" + }, "no-values-label": "Tidak ada nilai", "pagination-summary": "{{itemsRangeStart}}—{{displayedEnd}} dari {{numRows}} baris" }, @@ -8683,6 +8713,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8695,6 +8726,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8797,6 +8829,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9055,6 +9092,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Dasbor sudah disediakan dan dikelola oleh Grafana di instans cloud. Sebaiknya gunakan dasbor yang disediakan ke depannya. Jika Anda masih ingin menyalin dasbor ke instans cloud, ubah ID dasbor di JSON dasbor, simpan snapshot baru, lalu unggah lagi.", "datasource-already-managed": "Dasbor sudah disediakan dan dikelola oleh Grafana di instans cloud. Jika ini adalah sumber daya yang berbeda, atur UID lain dan coba lagi.", "datasource-invalid-url": "Ada sumber data yang memiliki URL tidak valid. Berikan URL yang valid dan coba lagi.", @@ -10281,10 +10320,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index cad6a896611..a336dbddb1e 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Modifica YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Errore durante il caricamento dei dettagli dell'origine dati", "edit": "Modifica", - "evaluations-to-fire": "Cicli di valutazione da attivare", "export": "Esporta", "folder": "Cartella", "group-loading-error": "Errore durante il caricamento del gruppo", "interval": "Intervallo", "namespace": "Namespace", - "pending-period": "Periodo di attesa", - "recording": "Registrazione", - "rule-name": "Nome regola" + "new": "" }, "group-edit": { "ds-error": "Errore durante il caricamento dei dettagli dell'origine dati", @@ -2237,6 +2237,9 @@ "label-export-all": "Esporta tutto" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Impossibile caricare l'editor di query a causa di: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Non trovi l'origine dei dati che desideri? Alcune origini dei dati non sono supportate per gli avvisi. Fai clic sull'icona per ulteriori informazioni." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Aggiungi nuovo Alertmanager", "builtin-alertmanager": "Alertmanager integrato", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "({{time}} fa)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "La cartella di destinazione non è vuota, alcune regole potrebbero essere sovrascritte o rimosse. Desideri davvero importare queste regole di avviso nelle regole gestite da Grafana?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Utilizzato su {{count}} dashboard", "usage-count_other": "Utilizzato su {{count}} dashboard" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Esiste già una dashboard con lo stesso nome nella cartella selezionata. Desideri comunque salvare questa dashboard?", "title-name-already-exists": "Nome già in uso" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Corrispondenza", "inspect-drawer-title": "Ispeziona valore", "inspect-menu-label": "Ispeziona valore", + "nested-table": { + "no-data": "" + }, "no-values-label": "Nessun valore", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}} di {{numRows}} righe" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Il dashboard è già stato predisposto e viene gestito da Grafana nell'istanza cloud. In futuro, ti consigliamo di utilizzare il dashboard con provisioning. Se desideri comunque copiare il dashboard nell'istanza cloud, modifica l'ID del dashboard nel JSON del dashboard, salva una nuova istantanea e carica di nuovo.", "datasource-already-managed": "L'origine dati è già stata predisposta e viene gestita da Grafana nell'istanza cloud. Se si tratta di una risorsa diversa, imposta un altro UID e riprova.", "datasource-invalid-url": "È presente un'origine dati con un URL non valido. Fornisci un URL valido e riprova.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index e1b74857c3e..8bbb86a8c04 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -504,6 +504,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAMLを編集", @@ -1499,15 +1502,12 @@ "group-details": { "ds-features-error": "データソースの読み込み中にエラーが発生しました", "edit": "編集", - "evaluations-to-fire": "発生する評価サイクル", "export": "エクスポート", "folder": "フォルダ", "group-loading-error": "グループの読み込み中にエラーが発生しました", "interval": "間隔", "namespace": "名前空間", - "pending-period": "保留期間", - "recording": "記録", - "rule-name": "ルール名" + "new": "" }, "group-edit": { "ds-error": "データソースの読み込み中にエラーが発生しました", @@ -2228,6 +2228,9 @@ "label-export-all": "すべてをエクスポート" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "次の理由によりクエリエディタを読み込めませんでした:{{errorMessage}}" }, @@ -2637,6 +2640,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "お探しのデータソースが見つかりませんか?一部のデータソースはアラートに対応していません。詳細についてはアイコンをクリックしてください。" }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "新しいAlertmanagerを追加", "builtin-alertmanager": "内蔵Alertmanager", @@ -2872,6 +2880,11 @@ "timestamp": { "time-ago": "({{time}}前)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "対象フォルダが空ではないため、一部のルールが上書きまたは削除される可能性があります。これらのアラートルールをGrafana管理ルールにインポートしてもよろしいですか?", @@ -5636,6 +5649,20 @@ "last-edited": "が{{timeAgo}}に実施", "usage-count_other": "{{count}}件のダッシュボードで使用中" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "選択したフォルダに同じ名前のダッシュボードがすでに存在します。このダッシュボードの保存を続行しますか?", "title-name-already-exists": "名前はすでに存在します" @@ -7963,6 +7990,9 @@ "filter-popup-match-case": "大文字/小文字を区別", "inspect-drawer-title": "値を検査", "inspect-menu-label": "値を検査", + "nested-table": { + "no-data": "" + }, "no-values-label": "値がありません", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}}({{numRows}}行)" }, @@ -8683,6 +8713,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8695,6 +8726,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8797,6 +8829,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9055,6 +9092,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "ダッシュボードは、クラウドインスタンスでGrafanaによってすでにプロビジョニングおよび管理されています。今後は、プロビジョニングされたダッシュボードの使用をお勧めします。それでもダッシュボードをクラウドインスタンスにコピーする場合は、ダッシュボードJSONでダッシュボードIDを変更し、新しいスナップショットを保存して再度アップロードしてください。", "datasource-already-managed": "ダッシュボードは、クラウドインスタンスでGrafanaによってすでにプロビジョニングおよび管理されています。これが別のリソースである場合は、別のUIDを設定して再試行してください。", "datasource-invalid-url": "URLが無効なデータソースがあります。有効なURLを入力して、再試行してください。", @@ -10281,10 +10320,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index a54baf4b8a7..79c29d444c2 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -504,6 +504,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAML 편집", @@ -1499,15 +1502,12 @@ "group-details": { "ds-features-error": "데이터 소스 세부 정보 로딩 중 오류 발생", "edit": "편집", - "evaluations-to-fire": "경고 발생 평가 주기", "export": "내보내기", "folder": "폴더", "group-loading-error": "그룹 로딩 중 오류 발생", "interval": "간격", "namespace": "네임스페이스", - "pending-period": "보류 기간", - "recording": "기록", - "rule-name": "규칙 이름" + "new": "" }, "group-edit": { "ds-error": "데이터 소스 세부 정보 로딩 중 오류 발생", @@ -2228,6 +2228,9 @@ "label-export-all": "모두 내보내기" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "{{errorMessage}}(으)로 인해 쿼리 편집기를 로딩할 수 없습니다" }, @@ -2637,6 +2640,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "원하는 데이터 소스를 찾을 수 없나요? 일부 데이터 소스는 경고용으로 지원되지 않습니다. 자세한 내용을 보려면 아이콘을 클릭하세요." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "새로운 Alertmanager 추가", "builtin-alertmanager": "내장 Alertmanager", @@ -2872,6 +2880,11 @@ "timestamp": { "time-ago": "({{time}} 전)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "대상 폴더가 비어 있지 않습니다. 일부 규칙이 덮어쓰이거나 제거될 수 있습니다. 정말 이 경고 규칙을 Grafana 관리 규칙으로 가져오시겠어요?", @@ -5636,6 +5649,20 @@ "last-edited": " 님({{timeAgo}}에)", "usage-count_other": "{{count}}개의 대시보드에서 사용됨" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "선택한 폴더에 동일한 이름의 대시보드가 이미 존재합니다. 그래도 이 대시보드를 저장하시겠어요?", "title-name-already-exists": "이미 존재하는 이름입니다" @@ -7963,6 +7990,9 @@ "filter-popup-match-case": "대소문자 일치", "inspect-drawer-title": "값 검사", "inspect-menu-label": "값 검사", + "nested-table": { + "no-data": "" + }, "no-values-label": "값 없음", "pagination-summary": "{{numRows}}개의 행 중에서 {{itemsRangeStart}}~{{displayedEnd}}" }, @@ -8683,6 +8713,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8695,6 +8726,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8797,6 +8829,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9055,6 +9092,8 @@ "resource-details": { "dismiss-button": "확인", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "대시보드가 이미 클라우드 인스턴스에서 Grafana에 의해 프로비저닝 및 관리되고 있습니다. 앞으로 프로비저닝된 대시보드를 사용하는 것이 좋습니다. 대시보드를 클라우드 인스턴스에 복사하려는 경우, 대시보드 JSON에서 대시보드 ID를 변경하고 새 스냅샷을 저장한 다음 다시 업로드하세요.", "datasource-already-managed": "데이터 소스가 이미 클라우드 인스턴스에서 Grafana에 의해 프로비저닝 및 관리되고 있습니다. 다른 리소스인 경우 다른 UID를 설정하고 다시 시도하세요.", "datasource-invalid-url": "잘못된 URL을 가진 데이터 소스가 있습니다. 유효한 URL을 입력하고 다시 시도해 주세요.", @@ -10281,10 +10320,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index d434ca6ae53..fcdb655b5d6 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAML bewerken", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Er is een fout opgetreden bij het laden van de gegevensbrondetails", "edit": "Bewerken", - "evaluations-to-fire": "Evaluatiecycli tot activering", "export": "Exporteren", "folder": "Map", "group-loading-error": "Er is een fout opgetreden bij het laden van de groep", "interval": "Interval", "namespace": "Namespace", - "pending-period": "Wachttijd", - "recording": "Opnemen", - "rule-name": "Regelnaam" + "new": "" }, "group-edit": { "ds-error": "Er is een fout opgetreden bij het laden van de gegevensbrondetails", @@ -2237,6 +2237,9 @@ "label-export-all": "Alles exporteren" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Kon querybewerker niet laden vanwege: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Kun je de gewenste gegevensbron niet vinden? Sommige gegevensbronnen worden niet ondersteund voor waarschuwingen. Klik op het pictogram voor meer informatie." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Nieuwe Alertmanager toevoegen", "builtin-alertmanager": "Ingebouwde Alertmanager", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "({{time}} geleden)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "De doelmap is niet leeg, sommige regels kunnen worden overschreven of verwijderd. Weet je zeker dat je deze waarschuwingsregels wilt importeren naar door Grafana beheerde regels?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Gebruikt op {{count}} dashboards", "usage-count_other": "Gebruikt op {{count}} dashboards" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Er bestaat al een dashboard met dezelfde naam in de geselecteerde map. Wil je dit dashboard nog steeds opslaan?", "title-name-already-exists": "Naam bestaat al" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Case matchen", "inspect-drawer-title": "Waarde inspecteren", "inspect-menu-label": "Waarde inspecteren", + "nested-table": { + "no-data": "" + }, "no-values-label": "Geen waarden", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}} van {{numRows}} rijen" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Dashboard is al provisioned en beheerd door Grafana in de cloudinstantie. We raden je aan om voortaan het provisioned dashboard te gebruiken. Als je het dashboard nog steeds naar de cloud-instantie wilt kopiëren, wijzig dan het dashboard-ID in het dashboard-JSON, sla een nieuw snapshot op en upload opnieuw.", "datasource-already-managed": "Dashboard is al provisioned en beheerd door Grafana in de cloudinstantie. Als dit een andere bron is, stel dan een andere UID in en probeer het opnieuw.", "datasource-invalid-url": "Er is een gegevensbron met een ongeldige URL. Geef een geldige URL op en probeer het opnieuw.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 1e636f38c03..f3936162e51 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -516,6 +516,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Edytuj YAML", @@ -1517,15 +1520,12 @@ "group-details": { "ds-features-error": "Błąd podczas ładowania szczegółów źródła danych", "edit": "Edytuj", - "evaluations-to-fire": "Cykle oceny do uruchomienia", "export": "Eksport", "folder": "Folder", "group-loading-error": "Błąd podczas ładowania grupy", "interval": "Interwał", "namespace": "Przestrzeń nazw", - "pending-period": "Okres oczekiwania", - "recording": "Nagrywanie", - "rule-name": "Nazwa reguły" + "new": "" }, "group-edit": { "ds-error": "Błąd podczas ładowania szczegółów źródła danych", @@ -2255,6 +2255,9 @@ "label-export-all": "Eksportuj wszystkie" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Nie udało się załadować edytora zapytań. Powód: {{errorMessage}}" }, @@ -2676,6 +2679,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Nie możesz znaleźć odpowiedniego źródła danych? Niektóre źródła danych nie są obsługiwane w przypadku alertów. Aby dowiedzieć się więcej, kliknij ikonę." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Dodaj nowego menedżera alertów", "builtin-alertmanager": "Wbudowany menedżer alertów", @@ -2911,6 +2919,11 @@ "timestamp": { "time-ago": "({{time}} temu)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Folder docelowy nie jest pusty. Niektóre reguły mogą zostać zastąpione lub usunięte. Na pewno chcesz zaimportować te reguły alertów do reguł zarządzanych przez usługę Grafana?", @@ -5696,6 +5709,20 @@ "usage-count_many": "Używane na {{count}} pulpitach", "usage-count_other": "Używane na {{count}} pulpitach" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "W wybranym folderze istnieje już pulpit o tej samej nazwie. Czy nadal chcesz zapisać ten pulpit?", "title-name-already-exists": "Nazwa już istnieje" @@ -8026,6 +8053,9 @@ "filter-popup-match-case": "Dopasuj wielkość liter", "inspect-drawer-title": "Sprawdź wartość", "inspect-menu-label": "Sprawdź wartość", + "nested-table": { + "no-data": "" + }, "no-values-label": "Brak wartości", "pagination-summary": "{{itemsRangeStart}}–{{displayedEnd}} z {{numRows}} wierszy" }, @@ -8770,6 +8800,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8782,6 +8813,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8884,6 +8916,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9142,6 +9179,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Pulpit jest już skonfigurowany w usłudze Grafana i zarządzany z jej poziomu w instancji w chmurze. Zalecamy korzystanie z pulpitu skonfigurowanego. Jeśli nadal chcesz skopiować pulpit do instancji w chmurze, zmień identyfikator pulpitu w jego pliku JSON, zapisz nową migawkę i prześlij ponownie.", "datasource-already-managed": "Źródło danych jest już skonfigurowane w usłudze Grafana i zarządzane z jej poziomu w instancji w chmurze. Jeśli jest to inny zasób, ustaw inny UID i spróbuj ponownie.", "datasource-invalid-url": "Istnieje źródło danych, które ma nieprawidłowy adres URL. Podaj prawidłowy adres URL i spróbuj ponownie.", @@ -10377,10 +10416,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 846cdd69fa0..af641b53d45 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Editar YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Erro ao carregar detalhes da fonte de dados", "edit": "Editar", - "evaluations-to-fire": "Ciclos de avaliação para acionar", "export": "Exportar", "folder": "Pasta", "group-loading-error": "Erro ao carregar o grupo", "interval": "Intervalo", "namespace": "Nomenclatura", - "pending-period": "Período pendente", - "recording": "Registrando", - "rule-name": "Nome da regra" + "new": "" }, "group-edit": { "ds-error": "Erro ao carregar detalhes da fonte de dados", @@ -2237,6 +2237,9 @@ "label-export-all": "Exportar tudo" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Não foi possível carregar o editor de consultas devido a: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Não está encontrando a fonte de dados que você procura? Algumas fontes de dados não são compatíveis com alertas. Clique no ícone para mais informações." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Adicionar novo Alertmanager", "builtin-alertmanager": "Alertmanager integrado", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(há {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "A pasta de destino não está vazia; algumas regras podem ser substituídas ou removidas. Tem certeza de que deseja importar essas regras de alerta para as regras gerenciadas pela Grafana?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Usado em {{count}} painéis", "usage-count_other": "Usado em {{count}} painéis" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Já existe um painel com o mesmo nome na pasta selecionada. Deseja salvar este painel mesmo assim?", "title-name-already-exists": "O nome já existe" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Diferenciar maiúsculas/minúsculas", "inspect-drawer-title": "Inspecionar valor", "inspect-menu-label": "Inspecionar valor", + "nested-table": { + "no-data": "" + }, "no-values-label": "Sem valores", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}} de {{numRows}} linhas" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "O painel de controle já está provisionado e é gerenciado pela Grafana na instância na nuvem. Recomendamos que você passe a usar o painel de controle provisionado. Se você ainda quiser copiar o painel de controle para a instância na nuvem, altere o ID do painel de controle no JSON do painel de controle, salve uma nova captura e envie novamente.", "datasource-already-managed": "A fonte de dados já está provisionada e é gerenciada pela Grafana na instância na nuvem. Caso se trate de outro recurso, defina outro UID e tente novamente.", "datasource-invalid-url": "Há uma fonte de dados com uma URL inválida. Forneça uma URL válida e tente novamente.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 946b1e08c05..dee16762e41 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Editar YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Erro ao carregar a detalhes da origem dos dados", "edit": "Editar", - "evaluations-to-fire": "Ciclos de avaliação a disparar", "export": "Exportar", "folder": "Pasta", "group-loading-error": "Erro ao carregar o grupo", "interval": "Intervalo", "namespace": "Espaço de nome", - "pending-period": "Período pendente", - "recording": "Gravação", - "rule-name": "Nome da regra" + "new": "" }, "group-edit": { "ds-error": "Erro ao carregar a detalhes da origem dos dados", @@ -2237,6 +2237,9 @@ "label-export-all": "Exportar tudo" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Não foi possível carregar o editor de consultas devido a: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Não encontra a origem de dados que pretende? Algumas origens de dados não são suportadas para alertas. Clique no ícone para obter mais informações." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Adicionar novo Alertmanager", "builtin-alertmanager": "Alertmanager integrado", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "(há {{time}})" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "A pasta de destino não está vazia, algumas regras podem ser substituídas ou removidas. Tem a certeza de que pretende importar estas regras de alerta para as regras geridas pela Grafana?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Utilizado em {{count}} painéis de controlo", "usage-count_other": "Utilizado em {{count}} painéis de controlo" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Já existe um painel de controlo com o mesmo nome na pasta selecionada. Ainda pretende guardar este painel de controlo?", "title-name-already-exists": "Nome já existente" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Diferenciar maiúsculas de minúsculas", "inspect-drawer-title": "Inspecionar valor", "inspect-menu-label": "Inspecionar valor", + "nested-table": { + "no-data": "" + }, "no-values-label": "Nenhum valor", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}} de {{numRows}} linhas" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "O painel de controlo já está provisionado e gerido pela Grafana na instância de nuvem. Recomendamos a utilização do painel de controlo provisionado a partir de agora. Se ainda pretender copiar o painel de controlo para a instância de nuvem, altere o ID do painel de controlo no JSON do painel de controlo, guarde um novo instantâneo e carregue novamente.", "datasource-already-managed": "A origem de dados já está provisionada e gerida pela Grafana na instância de nuvem. Se este for um recurso diferente, defina outro UID e tente novamente.", "datasource-invalid-url": "Existe uma origem de dados com um URL inválido. Forneça um URL válido e tente novamente.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index bbed7d87c55..f84ae7d9c8b 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -516,6 +516,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Редактировать YAML", @@ -1517,15 +1520,12 @@ "group-details": { "ds-features-error": "Ошибка при загрузке сведений об источнике данных", "edit": "Редактировать", - "evaluations-to-fire": "Циклы оценки для отправки оповещений", "export": "Экспорт", "folder": "Папка", "group-loading-error": "Ошибка при загрузке группы", "interval": "Интервал", "namespace": "Пространство имен", - "pending-period": "Период ожидания", - "recording": "Запись", - "rule-name": "Имя правила" + "new": "" }, "group-edit": { "ds-error": "Ошибка при загрузке сведений об источнике данных", @@ -2255,6 +2255,9 @@ "label-export-all": "Экспортировать всё" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Не удалось загрузить редактор запросов. Причина: {{errorMessage}}" }, @@ -2676,6 +2679,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Не удается найти нужный источник данных? Некоторые источники данных не поддерживаются для функции оповещения. Нажмите значок для получения подробной информации." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Добавить новый обработчик оповещений Alertmanager", "builtin-alertmanager": "Встроенный обработчик оповещений Alertmanager", @@ -2911,6 +2919,11 @@ "timestamp": { "time-ago": "({{time}} назад)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Целевая папка не пуста, некоторые правила могут быть перезаписаны или удалены. Действительно импортировать эти правила оповещения в правила, управляемые Grafana?", @@ -5696,6 +5709,20 @@ "usage-count_many": "Используется на {{count}} дашбордах", "usage-count_other": "Используется на {{count}} дашбордах" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Дашборд с таким названием уже существует в выбранной папке. Все равно сохранить дашборд?", "title-name-already-exists": "Название уже существует" @@ -8026,6 +8053,9 @@ "filter-popup-match-case": "С учетом регистра", "inspect-drawer-title": "Проверить значение", "inspect-menu-label": "Проверить значение", + "nested-table": { + "no-data": "" + }, "no-values-label": "Нет значений", "pagination-summary": "Строки: {{itemsRangeStart}} - {{displayedEnd}} из {{numRows}}" }, @@ -8770,6 +8800,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8782,6 +8813,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8884,6 +8916,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9142,6 +9179,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Дашборд уже подготовлен в облачном экземпляре и управляется Grafana. Рекомендуем в будущем использовать подготовленный дашборд. Если вы всё же хотите скопировать дашборд в облачный экземпляр, измените его идентификатор в JSON-файле, сохраните новый снимок и загрузите его снова.", "datasource-already-managed": "Источник данных уже подготовлен в облачном экземпляре и управляется Grafana. Если это другой ресурс, установите другой UID и повторите попытку.", "datasource-invalid-url": "Существует источник данных с недопустимым URL-адресом. Укажите действительный URL-адрес и повторите попытку.", @@ -10377,10 +10416,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index a1e6c576658..3d81843fb04 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "Redigera YAML", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Fel vid laddning av information för datakälla", "edit": "Redigera", - "evaluations-to-fire": "Utvärderingscykler att utlösa", "export": "Exportera", "folder": "Mapp", "group-loading-error": "Fel vid laddning av gruppen", "interval": "Intervall", "namespace": "Namnområde", - "pending-period": "Väntande period", - "recording": "Registrering", - "rule-name": "Regelns namn" + "new": "" }, "group-edit": { "ds-error": "Fel vid laddning av information för datakälla", @@ -2237,6 +2237,9 @@ "label-export-all": "Exportera alla" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Kunde inte ladda frågeredigerare på grund av: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Hittar du inte den datakälla du söker? Vissa datakällor stöds inte för larm. Klicka på ikonen för mer information." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Lägg till ny Alertmanager", "builtin-alertmanager": "Inbyggd Alertmanager", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "({{time}} sedan)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Målmappen är inte tom, vissa regler kan skrivas över eller tas bort. Är du säker på att du vill importera dessa larmregler till Grafana-hanterade regler?", @@ -5656,6 +5669,20 @@ "usage-count_one": "Används på {{count}} instrumentpaneler", "usage-count_other": "Används på {{count}} instrumentpaneler" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "En instrumentpanel med samma namn finns redan i den valda mappen. Vill du fortfarande spara denna instrumentpanel?", "title-name-already-exists": "Namnet finns redan" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Matcha gemener/versaler", "inspect-drawer-title": "Inspektera värde", "inspect-menu-label": "Inspektera värde", + "nested-table": { + "no-data": "" + }, "no-values-label": "Inga värden", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}} av {{numRows}} rader" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "OK", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Instrumentpanelen tillhandahålls och hanteras redan av Grafana i molninstansen. Vi rekommenderar att du använder den tillhandahållna instrumentpanelen framöver. Om du fortfarande vill kopiera instrumentpanelens molninstans ändrar du instrumentpanelens ID i instrumentpanelens JSON, sparar en ny ögonblicksbild och laddar sedan upp på nytt.", "datasource-already-managed": "Datakällan tillhandahålls och hanteras redan av Grafana i molninstansen. Om detta är en annan resurs, ange ett annat UID och försök igen.", "datasource-invalid-url": "Det finns en datakälla som har en ogiltig webbadress. Ange en giltig webbadress och försök igen.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 729e7089dd2..262db9aa789 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -508,6 +508,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "YAML düzenle", @@ -1505,15 +1508,12 @@ "group-details": { "ds-features-error": "Veri kaynağı ayrıntıları yüklenirken hata oluştu", "edit": "Düzenle", - "evaluations-to-fire": "Tetiklenecek değerlendirme döngüleri", "export": "Dışa aktar", "folder": "Klasör", "group-loading-error": "Grup yüklenirken hata oluştu", "interval": "Aralık", "namespace": "Ad alanı", - "pending-period": "Bekleme süresi", - "recording": "Kayıt", - "rule-name": "Kural adı" + "new": "" }, "group-edit": { "ds-error": "Veri kaynağı ayrıntıları yüklenirken hata oluştu", @@ -2237,6 +2237,9 @@ "label-export-all": "Tümünü dışa aktar" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "Sorgu düzenleyici şu nedenle yüklenemedi: {{errorMessage}}" }, @@ -2650,6 +2653,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "İstediğiniz veri kaynağını bulamıyor musunuz? Bazı veri kaynakları uyarılar için desteklenmiyor. Daha fazla bilgi için simgeye tıklayın." }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "Yeni Alertmanager ekle", "builtin-alertmanager": "Yerleşik Alertmanager", @@ -2885,6 +2893,11 @@ "timestamp": { "time-ago": "({{time}} önce)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "Hedef klasör boş değil, bazı kuralların üzerine yazılabilir veya silinebilir. Bu uyarı kurallarını Grafana tarafından yönetilen kurallara içe aktarmak istediğinize emin misiniz?", @@ -5656,6 +5669,20 @@ "usage-count_one": "{{count}} panoda kullanılıyor", "usage-count_other": "{{count}} panoda kullanılıyor" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "Seçilen klasörde aynı isimde bir pano zaten mevcut. Yine de bu panoyu kaydetmek istiyor musunuz?", "title-name-already-exists": "Bu ad zaten mevcut" @@ -7984,6 +8011,9 @@ "filter-popup-match-case": "Büyük/küçük harf duyarlılığı", "inspect-drawer-title": "Değeri incele", "inspect-menu-label": "Değeri incele", + "nested-table": { + "no-data": "" + }, "no-values-label": "Değer yok", "pagination-summary": "{{itemsRangeStart}} - {{displayedEnd}}/{{numRows}} satır" }, @@ -8712,6 +8742,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8724,6 +8755,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8826,6 +8858,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9084,6 +9121,8 @@ "resource-details": { "dismiss-button": "Tamam", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "Pano zaten bulut örneğinde Grafana tarafından sağlanmış ve yönetilmektedir. Bundan sonra sağlanan panoyu kullanmanızı öneririz. Yine de panoyu bulut örneğine kopyalamak istiyorsanız pano JSON'unda pano kimliğini değiştirin, yeni bir anlık görüntü kaydedin ve tekrar yükleyin.", "datasource-already-managed": "Veri kaynağı zaten bulut örneğinde Grafana tarafından sağlanmış ve yönetilmektedir. Bu farklı bir kaynaksa başka bir UID belirleyin ve tekrar deneyin.", "datasource-invalid-url": "Geçersiz bir URL içeren bir veri kaynağı var. Geçerli bir URL girin ve tekrar deneyin.", @@ -10313,10 +10352,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 09902858965..bfaef6559b7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -504,6 +504,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "编辑 YAML", @@ -1499,15 +1502,12 @@ "group-details": { "ds-features-error": "加载数据源详情时出错", "edit": "编辑", - "evaluations-to-fire": "待触发的评估周期", "export": "导出", "folder": "文件夹", "group-loading-error": "加载组时出错", "interval": "间隔", "namespace": "命名空间", - "pending-period": "待处理时段", - "recording": "录制", - "rule-name": "规则名称" + "new": "" }, "group-edit": { "ds-error": "加载数据源详情时出错", @@ -2228,6 +2228,9 @@ "label-export-all": "导出全部" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "由于以下原因,无法加载查询编辑器:{{errorMessage}}" }, @@ -2637,6 +2640,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "找不到您想要的数据源?某些数据源不支持警报。点击图标以获取更多信息。" }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "添加新的 Alertmanager", "builtin-alertmanager": "内置 Alertmanager", @@ -2872,6 +2880,11 @@ "timestamp": { "time-ago": "({{time}}前)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "目标文件夹不为空,某些规则可能会被覆盖或移除。您确定要将这些警报规则导入到 Grafana 管理的规则吗?", @@ -5636,6 +5649,20 @@ "last-edited": "{{timeAgo}}由 ", "usage-count_other": "用于 {{count}} 个数据面板" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "所选文件夹中已存在具有相同名称的数据面板。您仍然要保存此数据面板吗?", "title-name-already-exists": "名称已存在" @@ -7963,6 +7990,9 @@ "filter-popup-match-case": "区分大小写", "inspect-drawer-title": "检查值", "inspect-menu-label": "检查值", + "nested-table": { + "no-data": "" + }, "no-values-label": "没有值", "pagination-summary": "第 {{itemsRangeStart}} - {{displayedEnd}} 行,共 {{numRows}} 行" }, @@ -8683,6 +8713,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8695,6 +8726,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8797,6 +8829,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9055,6 +9092,8 @@ "resource-details": { "dismiss-button": "好", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "数据面板已由 Grafana 在云实例中配置和管理。我们建议您今后使用已配置的数据面板。如果您仍然希望将数据面板复制到云实例,请更改数据面板 JSON 中的数据面板 ID,保存新的快照并再次上传。", "datasource-already-managed": "数据源已由 Grafana 在云实例中配置和管理。如果这是其他资源,请设置另一个 UID,然后重试。", "datasource-invalid-url": "有一个数据源的 URL 无效。请提供有效的 URL,然后重试。", @@ -10281,10 +10320,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index b0655b9c6ca..e741a02996c 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -504,6 +504,9 @@ } } }, + "alert-rule": { + "term": "" + }, "alert-rule-form": { "action-buttons": { "edit-yaml": "編輯 YAML", @@ -1499,15 +1502,12 @@ "group-details": { "ds-features-error": "載入資料來源詳細資料時發生錯誤", "edit": "編輯", - "evaluations-to-fire": "待觸發的評估週期", "export": "匯出", "folder": "資料夾", "group-loading-error": "載入群組時發生錯誤", "interval": "間隔", "namespace": "命名空間", - "pending-period": "待處理期間", - "recording": "錄製", - "rule-name": "規則名稱" + "new": "" }, "group-edit": { "ds-error": "載入資料來源詳細資料時發生錯誤", @@ -2228,6 +2228,9 @@ "label-export-all": "匯出全部" } }, + "recording-rule": { + "term": "" + }, "recording-rule-editor": { "error-no-query-editor": "由於以下原因,無法載入查詢編輯器:{{errorMessage}}" }, @@ -2637,6 +2640,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "找不到您想要的資料來源嗎?某些資料來源不支援警報。按一下該圖示了解更多資訊。" }, + "settings": { + "tabs": { + "alert-managers": "" + } + }, "settings-content": { "add-new-alertmanager": "新增 Alertmanager", "builtin-alertmanager": "內建的 Alertmanager", @@ -2872,6 +2880,11 @@ "timestamp": { "time-ago": "({{time}} 前)" }, + "titles": { + "group-view": { + "subtitle": "" + } + }, "to-gma": { "confirm-modal": { "body": "目標資料夾不為空,某些規則可能會被覆寫或移除。確定要將這些警報規則匯入至 Grafana 管理的規則嗎?", @@ -5636,6 +5649,20 @@ "last-edited": "在 {{timeAgo}}前", "usage-count_other": "用於 {{count}} 個儀表板" }, + "move-provisioned-dashboard-form": { + "api-error": "", + "cancel-action": "", + "current-file-not-found": "", + "drawer-title": "", + "file-load-error": "", + "loading-file-data": "", + "move-action": "", + "move-read-only-message": "", + "moving": "", + "partial-failure-warning": "", + "target-path-label": "", + "title-this-repository-is-read-only": "" + }, "name-already-exists-error": { "body-name-already-exists": "所選資料夾中已存在名稱相同的儀表板。仍要儲存此儀表板嗎?", "title-name-already-exists": "名稱已存在" @@ -7963,6 +7990,9 @@ "filter-popup-match-case": "區分大小寫", "inspect-drawer-title": "檢查值", "inspect-menu-label": "檢查值", + "nested-table": { + "no-data": "" + }, "no-values-label": "沒有數值", "pagination-summary": "{{itemsRangeStart}} - 第 {{displayedEnd}} 列,共 {{numRows}} 列" }, @@ -8683,6 +8713,7 @@ }, "fields-section": "", "hide-log-line": "", + "inline-mode": "", "links-section": "", "log-line-field": "", "log-line-section": "", @@ -8695,6 +8726,7 @@ "search-placeholder": "", "show-context": "", "show-log-line": "", + "sidebar-mode": "", "unpin-line": "" }, "log-line-menu": { @@ -8797,6 +8829,11 @@ }, "name-common-labels": "", "name-deduplication": "", + "name-details-mode": "", + "name-details-options": { + "label-inline": "", + "label-sidebar": "" + }, "name-enable-infinite-scrolling": "", "name-enable-log-details": "", "name-enable-syntax-highlighting": "", @@ -9055,6 +9092,8 @@ "resource-details": { "dismiss-button": "確定", "error-messages": { + "alert-rules-group-quota-reached": "", + "alert-rules-quota-reached": "", "dashboard-already-managed": "儀表板已由 Grafana 在雲端執行個體中佈建和管理。我們建議您將來使用已設定的儀表板。若您仍然希望將儀表板複製至雲端執行個體,請變更儀表板 JSON 中的儀表板 ID,儲存新的快照並重新上傳。", "datasource-already-managed": "資料來源已由 Grafana 在雲端執行個體中佈建和管理。如果這是不同的資源,請設定另一個 UID 並再試一次。", "datasource-invalid-url": "有一個資料來源的網址無效。請提供有效的網址,然後再試一次。", @@ -10281,10 +10320,8 @@ "open-pull-request-in-git-hub": "", "view-pull-request-in-git-hub": "" }, - "title-dashboard-loaded-branch-git-hub": "", - "title-dashboard-loaded-pull-request-git-hub": "", - "title-folder-created-branch-git-hub": "", - "title-folder-created-pull-request-git-hub": "" + "title-created-branch-git-hub": "", + "title-loaded-pull-request-git-hub": "" }, "provisioning": { "banner": { From 38db533e6e18a338616dbbe1ececbe11d2e0fd3a Mon Sep 17 00:00:00 2001 From: Yunwen Zheng 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 = ( + + {components.map((c) => { + const id = getComponentIdFromComponentMeta(pluginId, c); + return ( + setDockedComponentId(dockedComponentId === id ? undefined : id)} + /> + ); + })} + + ); + + return isPluginActive ? ( + setDockedComponentId(undefined)} + pluginId={pluginId} + /> + ) : ( + + + + ); + }; - const MenuItems = ( - - {components.map((c) => { - const id = getComponentIdFromComponentMeta(c.pluginId, c); - return ( - { - if (isOpen && dockedComponentId === id) { - setDockedComponentId(undefined); - } else { - setDockedComponentId(id); - } - }} - /> - ); - })} - - ); 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": {