diff --git a/.github/pr-commands.json b/.github/pr-commands.json index ab42e204fce..bfff4753773 100644 --- a/.github/pr-commands.json +++ b/.github/pr-commands.json @@ -365,7 +365,9 @@ "type": "changedfiles", "matches": [ "public/app/plugins/panel/gauge/**/*", - "/packages/grafana-ui/src/components/Gauge/**/*" + "public/app/plugins/panel/radialbar/**/*", + "/packages/grafana-ui/src/components/Gauge/**/*", + "/packages/grafana-ui/src/components/RadialGauge/**/*" ], "action": "updateLabel", "addLabel": "area/panel/gauge" diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index ca5a4702d66..c1b2c7acd25 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -165,6 +165,7 @@ require ( github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/sqlds/v4 v4.2.7 // indirect diff --git a/apps/alerting/notifications/kinds/v0alpha1/template_spec.cue b/apps/alerting/notifications/kinds/v0alpha1/template_spec.cue index 4c3157f39a4..297d3331eeb 100644 --- a/apps/alerting/notifications/kinds/v0alpha1/template_spec.cue +++ b/apps/alerting/notifications/kinds/v0alpha1/template_spec.cue @@ -1,6 +1,9 @@ package v0alpha1 +TemplateKind: *"grafana" | "mimir" + TemplateGroupSpec: { title: string content: string + kind: TemplateKind } diff --git a/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1/templategroup_spec_gen.go b/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1/templategroup_spec_gen.go index ecba2959665..2f4fee39b9e 100644 --- a/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1/templategroup_spec_gen.go +++ b/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1/templategroup_spec_gen.go @@ -2,13 +2,24 @@ package v0alpha1 +// +k8s:openapi-gen=true +type TemplateGroupTemplateKind string + +const ( + TemplateGroupTemplateKindGrafana TemplateGroupTemplateKind = "grafana" + TemplateGroupTemplateKindMimir TemplateGroupTemplateKind = "mimir" +) + // +k8s:openapi-gen=true type TemplateGroupSpec struct { - Title string `json:"title"` - Content string `json:"content"` + Title string `json:"title"` + Content string `json:"content"` + Kind TemplateGroupTemplateKind `json:"kind"` } // NewTemplateGroupSpec creates a new TemplateGroupSpec object. func NewTemplateGroupSpec() *TemplateGroupSpec { - return &TemplateGroupSpec{} + return &TemplateGroupSpec{ + Kind: TemplateGroupTemplateKindGrafana, + } } diff --git a/apps/alerting/notifications/pkg/apis/alertingnotifications_manifest.go b/apps/alerting/notifications/pkg/apis/alertingnotifications_manifest.go index e16f443a4f6..18447f62760 100644 --- a/apps/alerting/notifications/pkg/apis/alertingnotifications_manifest.go +++ b/apps/alerting/notifications/pkg/apis/alertingnotifications_manifest.go @@ -26,7 +26,7 @@ var ( rawSchemaRoutingTreev0alpha1 = []byte(`{"Matcher":{"additionalProperties":false,"properties":{"label":{"type":"string"},"type":{"enum":["=","!=","=~","!~"],"type":"string"},"value":{"type":"string"}},"required":["type","label","value"],"type":"object"},"Route":{"additionalProperties":false,"properties":{"active_time_intervals":{"items":{"type":"string"},"type":"array"},"continue":{"type":"boolean"},"group_by":{"items":{"type":"string"},"type":"array"},"group_interval":{"type":"string"},"group_wait":{"type":"string"},"matchers":{"items":{"$ref":"#/components/schemas/Matcher"},"type":"array"},"mute_time_intervals":{"items":{"type":"string"},"type":"array"},"receiver":{"type":"string"},"repeat_interval":{"type":"string"},"routes":{"items":{"$ref":"#/components/schemas/Route"},"type":"array"}},"required":["continue"],"type":"object"},"RouteDefaults":{"additionalProperties":false,"properties":{"group_by":{"items":{"type":"string"},"type":"array"},"group_interval":{"type":"string"},"group_wait":{"type":"string"},"receiver":{"type":"string"},"repeat_interval":{"type":"string"}},"required":["receiver"],"type":"object"},"RoutingTree":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"defaults":{"$ref":"#/components/schemas/RouteDefaults"},"routes":{"items":{"$ref":"#/components/schemas/Route"},"type":"array"}},"required":["defaults","routes"],"type":"object"}}`) versionSchemaRoutingTreev0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaRoutingTreev0alpha1, &versionSchemaRoutingTreev0alpha1) - rawSchemaTemplateGroupv0alpha1 = []byte(`{"TemplateGroup":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"content":{"type":"string"},"title":{"type":"string"}},"required":["title","content"],"type":"object"}}`) + rawSchemaTemplateGroupv0alpha1 = []byte(`{"TemplateGroup":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"TemplateKind":{"enum":["grafana","mimir"],"type":"string"},"spec":{"additionalProperties":false,"properties":{"content":{"type":"string"},"kind":{"$ref":"#/components/schemas/TemplateKind","default":"grafana"},"title":{"type":"string"}},"required":["title","content","kind"],"type":"object"}}`) versionSchemaTemplateGroupv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaTemplateGroupv0alpha1, &versionSchemaTemplateGroupv0alpha1) rawSchemaTimeIntervalv0alpha1 = []byte(`{"Interval":{"additionalProperties":false,"properties":{"days_of_month":{"items":{"type":"string"},"type":"array"},"location":{"type":"string"},"months":{"items":{"type":"string"},"type":"array"},"times":{"items":{"$ref":"#/components/schemas/TimeRange"},"type":"array"},"weekdays":{"items":{"type":"string"},"type":"array"},"years":{"items":{"type":"string"},"type":"array"}},"type":"object"},"TimeInterval":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"TimeRange":{"additionalProperties":false,"properties":{"end_time":{"type":"string"},"start_time":{"type":"string"}},"required":["start_time","end_time"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"name":{"type":"string"},"time_intervals":{"items":{"$ref":"#/components/schemas/Interval"},"type":"array"}},"required":["name","time_intervals"],"type":"object"}}`) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.value-mapping-and-overrides.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.value-mapping-and-overrides.json new file mode 100644 index 00000000000..54507dafbea --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.value-mapping-and-overrides.json @@ -0,0 +1,864 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "ValueMap Example", + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "up" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "critical": { + "text": "Critical!", + "color": "red", + "index": 0 + }, + "ok": { + "text": "OK", + "color": "green", + "index": 2 + }, + "warning": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "RangeMap Example", + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "cpu_usage_percent" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "gauge", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "range", + "options": { + "from": 0, + "to": 50, + "result": { + "text": "Low", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 50, + "to": 80, + "result": { + "text": "Medium", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "range", + "options": { + "from": 80, + "to": 100, + "result": { + "text": "High", + "color": "red", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "RegexMap Example", + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "log_level" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "regex", + "options": { + "pattern": "/^error.*/", + "result": { + "text": "Error", + "color": "red", + "index": 0 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^warn.*/", + "result": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^info.*/", + "result": { + "text": "Info", + "color": "blue", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "SpecialValueMap Example", + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "some_metric" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "No Data", + "color": "gray", + "index": 0 + } + } + }, + { + "type": "special", + "options": { + "match": "nan", + "result": { + "text": "Not a Number", + "color": "gray", + "index": 1 + } + } + }, + { + "type": "special", + "options": { + "match": "null+nan", + "result": { + "text": "N/A", + "color": "gray", + "index": 2 + } + } + }, + { + "type": "special", + "options": { + "match": "true", + "result": { + "text": "Yes", + "color": "green", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "false", + "result": { + "text": "No", + "color": "red", + "index": 4 + } + } + }, + { + "type": "special", + "options": { + "match": "empty", + "result": { + "text": "Empty", + "color": "gray", + "index": 5 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + } + } + } + }, + "panel-6": { + "kind": "Panel", + "spec": { + "id": 6, + "title": "Empty Properties Override Example", + "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "empty_override_metric" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "field_with_empty_override" + }, + "properties": [] + } + ] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Combined Mappings and Overrides Example", + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "combined_metric" + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "secondary_metric" + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "table", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "failure": { + "text": "Failure", + "color": "red", + "index": 1 + }, + "success": { + "text": "Success", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 0, + "to": 100, + "result": { + "text": "In Range", + "color": "blue", + "index": 2 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "text": "ID Format", + "color": "purple", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "Missing", + "color": "gray", + "index": 4 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 16, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 24, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Value Mapping and Overrides Test", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } + } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json new file mode 100644 index 00000000000..bd8e440efb2 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json @@ -0,0 +1,640 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "liveNow": false, + "panels": [ + { + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "critical": { + "color": "red", + "index": 0, + "text": "Critical!" + }, + "ok": { + "color": "green", + "index": 2, + "text": "OK" + }, + "warning": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "up", + "refId": "A" + } + ], + "title": "ValueMap Example", + "type": "stat" + }, + { + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0, + "text": "Low" + }, + "to": 50 + }, + "type": "range" + }, + { + "options": { + "from": 50, + "result": { + "color": "orange", + "index": 1, + "text": "Medium" + }, + "to": 80 + }, + "type": "range" + }, + { + "options": { + "from": 80, + "result": { + "color": "red", + "index": 2, + "text": "High" + }, + "to": 100 + }, + "type": "range" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "cpu_usage_percent", + "refId": "A" + } + ], + "title": "RangeMap Example", + "type": "gauge" + }, + { + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "pattern": "/^error.*/", + "result": { + "color": "red", + "index": 0, + "text": "Error" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^warn.*/", + "result": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^info.*/", + "result": { + "color": "blue", + "index": 2, + "text": "Info" + } + }, + "type": "regex" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "log_level", + "refId": "A" + } + ], + "title": "RegexMap Example", + "type": "stat" + }, + { + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 0, + "text": "No Data" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "gray", + "index": 1, + "text": "Not a Number" + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "gray", + "index": 2, + "text": "N/A" + } + }, + "type": "special" + }, + { + "options": { + "match": "true", + "result": { + "color": "green", + "index": 3, + "text": "Yes" + } + }, + "type": "special" + }, + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 4, + "text": "No" + } + }, + "type": "special" + }, + { + "options": { + "match": "empty", + "result": { + "color": "gray", + "index": 5, + "text": "Empty" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "some_metric", + "refId": "A" + } + ], + "title": "SpecialValueMap Example", + "type": "stat" + }, + { + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "failure": { + "color": "red", + "index": 1, + "text": "Failure" + }, + "success": { + "color": "green", + "index": 0, + "text": "Success" + } + }, + "type": "value" + }, + { + "options": { + "from": 0, + "result": { + "color": "blue", + "index": 2, + "text": "In Range" + }, + "to": 100 + }, + "type": "range" + }, + { + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "color": "purple", + "index": 3, + "text": "ID Format" + } + }, + "type": "regex" + }, + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 4, + "text": "Missing" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "combined_metric", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "secondary_metric", + "refId": "B" + } + ], + "title": "Combined Mappings and Overrides Example", + "type": "table" + }, + { + "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", + "fieldConfig": { + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "field_with_empty_override" + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 6, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "empty_override_metric", + "refId": "A" + } + ], + "title": "Empty Properties Override Example", + "type": "stat" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Value Mapping and Overrides Test" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json new file mode 100644 index 00000000000..07d899db46c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json @@ -0,0 +1,640 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "liveNow": false, + "panels": [ + { + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "critical": { + "color": "red", + "index": 0, + "text": "Critical!" + }, + "ok": { + "color": "green", + "index": 2, + "text": "OK" + }, + "warning": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "up", + "refId": "A" + } + ], + "title": "ValueMap Example", + "type": "stat" + }, + { + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0, + "text": "Low" + }, + "to": 50 + }, + "type": "range" + }, + { + "options": { + "from": 50, + "result": { + "color": "orange", + "index": 1, + "text": "Medium" + }, + "to": 80 + }, + "type": "range" + }, + { + "options": { + "from": 80, + "result": { + "color": "red", + "index": 2, + "text": "High" + }, + "to": 100 + }, + "type": "range" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "cpu_usage_percent", + "refId": "A" + } + ], + "title": "RangeMap Example", + "type": "gauge" + }, + { + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "pattern": "/^error.*/", + "result": { + "color": "red", + "index": 0, + "text": "Error" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^warn.*/", + "result": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^info.*/", + "result": { + "color": "blue", + "index": 2, + "text": "Info" + } + }, + "type": "regex" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "log_level", + "refId": "A" + } + ], + "title": "RegexMap Example", + "type": "stat" + }, + { + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 0, + "text": "No Data" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "gray", + "index": 1, + "text": "Not a Number" + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "gray", + "index": 2, + "text": "N/A" + } + }, + "type": "special" + }, + { + "options": { + "match": "true", + "result": { + "color": "green", + "index": 3, + "text": "Yes" + } + }, + "type": "special" + }, + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 4, + "text": "No" + } + }, + "type": "special" + }, + { + "options": { + "match": "empty", + "result": { + "color": "gray", + "index": 5, + "text": "Empty" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "some_metric", + "refId": "A" + } + ], + "title": "SpecialValueMap Example", + "type": "stat" + }, + { + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "failure": { + "color": "red", + "index": 1, + "text": "Failure" + }, + "success": { + "color": "green", + "index": 0, + "text": "Success" + } + }, + "type": "value" + }, + { + "options": { + "from": 0, + "result": { + "color": "blue", + "index": 2, + "text": "In Range" + }, + "to": 100 + }, + "type": "range" + }, + { + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "color": "purple", + "index": 3, + "text": "ID Format" + } + }, + "type": "regex" + }, + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 4, + "text": "Missing" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "combined_metric", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "secondary_metric", + "refId": "B" + } + ], + "title": "Combined Mappings and Overrides Example", + "type": "table" + }, + { + "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", + "fieldConfig": { + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "field_with_empty_override" + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 6, + "options": {}, + "pluginVersion": "", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "expr": "empty_override_metric", + "refId": "A" + } + ], + "title": "Empty Properties Override Example", + "type": "stat" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 42, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Value Mapping and Overrides Test" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v2alpha1.json new file mode 100644 index 00000000000..425b914eafb --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v2alpha1.json @@ -0,0 +1,850 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "ValueMap Example", + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "up" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "critical": { + "text": "Critical!", + "color": "red", + "index": 0 + }, + "ok": { + "text": "OK", + "color": "green", + "index": 2 + }, + "warning": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "RangeMap Example", + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "cpu_usage_percent" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "gauge", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "range", + "options": { + "from": 0, + "to": 50, + "result": { + "text": "Low", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 50, + "to": 80, + "result": { + "text": "Medium", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "range", + "options": { + "from": 80, + "to": 100, + "result": { + "text": "High", + "color": "red", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "RegexMap Example", + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "log_level" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "regex", + "options": { + "pattern": "/^error.*/", + "result": { + "text": "Error", + "color": "red", + "index": 0 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^warn.*/", + "result": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^info.*/", + "result": { + "text": "Info", + "color": "blue", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "SpecialValueMap Example", + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "some_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "No Data", + "color": "gray", + "index": 0 + } + } + }, + { + "type": "special", + "options": { + "match": "nan", + "result": { + "text": "Not a Number", + "color": "gray", + "index": 1 + } + } + }, + { + "type": "special", + "options": { + "match": "null+nan", + "result": { + "text": "N/A", + "color": "gray", + "index": 2 + } + } + }, + { + "type": "special", + "options": { + "match": "true", + "result": { + "text": "Yes", + "color": "green", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "false", + "result": { + "text": "No", + "color": "red", + "index": 4 + } + } + }, + { + "type": "special", + "options": { + "match": "empty", + "result": { + "text": "Empty", + "color": "gray", + "index": 5 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Combined Mappings and Overrides Example", + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "combined_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "secondary_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "table", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "failure": { + "text": "Failure", + "color": "red", + "index": 1 + }, + "success": { + "text": "Success", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 0, + "to": 100, + "result": { + "text": "In Range", + "color": "blue", + "index": 2 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "text": "ID Format", + "color": "purple", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "Missing", + "color": "gray", + "index": 4 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + } + } + } + } + }, + "panel-6": { + "kind": "Panel", + "spec": { + "id": 6, + "title": "Empty Properties Override Example", + "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "empty_override_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "field_with_empty_override" + }, + "properties": [] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 16, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 24, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-6" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Value Mapping and Overrides Test", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v2beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 438aad5ead7..e2a3e17843c 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1973,16 +1973,16 @@ func convertFieldConfigOverridesToV1(overrides []dashv2alpha1.DashboardV2alpha1F "options": override.Matcher.Options, } + properties := make([]map[string]interface{}, 0, len(override.Properties)) if len(override.Properties) > 0 { - properties := make([]map[string]interface{}, 0, len(override.Properties)) for _, prop := range override.Properties { properties = append(properties, map[string]interface{}{ "id": prop.Id, "value": prop.Value, }) } - overrideMap["properties"] = properties } + overrideMap["properties"] = properties result = append(result, overrideMap) } @@ -2074,11 +2074,9 @@ func convertRegexMapToV1(regexMap *dashv2alpha1.DashboardRegexMap) map[string]in return nil } - options := []map[string]interface{}{ - { - "pattern": regexMap.Options.Pattern, - "result": convertValueMappingResultToV1(regexMap.Options.Result), - }, + options := map[string]interface{}{ + "pattern": regexMap.Options.Pattern, + "result": convertValueMappingResultToV1(regexMap.Options.Result), } return map[string]interface{}{ diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index e2084f9b8d8..edcf18ea3e3 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -24,6 +24,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -35,16 +36,21 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect github.com/aws/smithy-go v1.23.1 // indirect + github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bluele/gcache v0.0.2 // indirect github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -94,6 +100,7 @@ require ( github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apimachinery v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/sqlds/v4 v4.2.7 // indirect @@ -142,11 +149,15 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/nikunjy/rules v1.5.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/open-feature/go-sdk v1.16.0 // indirect + github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6 // indirect + github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect @@ -165,6 +176,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect github.com/tjhop/slog-gokit v0.1.5 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -179,6 +191,7 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect @@ -186,6 +199,8 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index dcbf9d3a129..c5fbc7a39a5 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -4,9 +4,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -38,12 +42,18 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= +github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= @@ -60,6 +70,8 @@ github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wX github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= @@ -69,6 +81,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4 h1:92HSmB9bwM/o0ZvrCpcvTP2EsPXSkKtAniIr2W/dcIM= +github.com/diegoholiveira/jsonlogic/v3 v3.7.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -219,6 +233,8 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= +github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= @@ -366,6 +382,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nikunjy/rules v1.5.0 h1:KJDSLOsFhwt7kcXUyZqwkgrQg5YoUwj+TVu6ItCQShw= +github.com/nikunjy/rules v1.5.0/go.mod h1:TlZtZdBChrkqi8Lr2AXocme8Z7EsbxtFdDoKeI6neBQ= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -380,6 +398,12 @@ github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/open-feature/go-sdk v1.16.0 h1:5NCHYv5slvNBIZhYXAzAufo0OI59OACZ5tczVqSE+Tg= +github.com/open-feature/go-sdk v1.16.0/go.mod h1:EIF40QcoYT1VbQkMPy2ZJH4kvZeY+qGUXAorzSWgKSo= +github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6 h1:megzzlQGjsRVWDX8oJnLaa5eEcsAHekiL4Uvl3jSAcY= +github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6/go.mod h1:K1gDKvt76CGFLSUMHUydd5ba2V5Cv69gQZsdbnXhAm8= +github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 h1:WinefYxeVx5rV0uQmuWbxQf8iACu/JiRubo5w0saToc= +github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6/go.mod h1:Dwcaoma6lZVqYwyfVlY7eB6RXbG+Ju3b9cnpTlUN+Hc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -465,6 +489,10 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= +github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= +github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= +github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= github.com/tjhop/slog-gokit v0.1.5 h1:ayloIUi5EK2QYB8eY4DOPO95/mRtMW42lUkp3quJohc= github.com/tjhop/slog-gokit v0.1.5/go.mod h1:yA48zAHvV+Sg4z4VRyeFyFUNNXd3JY5Zg84u3USICq0= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= @@ -513,6 +541,8 @@ go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bn go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= @@ -532,8 +562,12 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= diff --git a/apps/plugins/kinds/meta.cue b/apps/plugins/kinds/meta.cue index 34a7b9aaf12..d29308e2ca2 100644 --- a/apps/plugins/kinds/meta.cue +++ b/apps/plugins/kinds/meta.cue @@ -5,7 +5,25 @@ metaV0Alpha1: { scope: "Namespaced" schema: { spec: { - pluginJSON: #JSONData, + pluginJson: #JSONData + class: "core" | "external" + module?: { + path: string + hash?: string + loadingStrategy?: "fetch" | "script" + } + baseURL?: string + signature?: { + status: "internal" | "valid" | "invalid" | "modified" | "unsigned" + type?: "grafana" | "commercial" | "community" | "private" | "private-glob" + org?: string + } + angular?: { + detected: bool + } + translations?: [string]: string + // +listType=atomic + children?: [...string] } } } diff --git a/apps/plugins/kinds/plugin.cue b/apps/plugins/kinds/plugin.cue index dc16a3f6de4..854448a5b72 100644 --- a/apps/plugins/kinds/plugin.cue +++ b/apps/plugins/kinds/plugin.cue @@ -9,7 +9,6 @@ pluginV0Alpha1: { id: string version: string url?: string - class: "core" | "external" } } } diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go index 9598da22ef7..f3671a040db 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -208,13 +208,21 @@ func NewMetaExtensions() *MetaExtensions { // +k8s:openapi-gen=true type MetaSpec struct { - PluginJSON MetaJSONData `json:"pluginJSON"` + PluginJson MetaJSONData `json:"pluginJson"` + Class MetaSpecClass `json:"class"` + Module *MetaV0alpha1SpecModule `json:"module,omitempty"` + BaseURL *string `json:"baseURL,omitempty"` + Signature *MetaV0alpha1SpecSignature `json:"signature,omitempty"` + Angular *MetaV0alpha1SpecAngular `json:"angular,omitempty"` + Translations map[string]string `json:"translations,omitempty"` + // +listType=atomic + Children []string `json:"children,omitempty"` } // NewMetaSpec creates a new MetaSpec object. func NewMetaSpec() *MetaSpec { return &MetaSpec{ - PluginJSON: *NewMetaJSONData(), + PluginJson: *NewMetaJSONData(), } } @@ -412,6 +420,40 @@ func NewMetaV0alpha1ExtensionsExtensionPoints() *MetaV0alpha1ExtensionsExtension return &MetaV0alpha1ExtensionsExtensionPoints{} } +// +k8s:openapi-gen=true +type MetaV0alpha1SpecModule struct { + Path string `json:"path"` + Hash *string `json:"hash,omitempty"` + LoadingStrategy *MetaV0alpha1SpecModuleLoadingStrategy `json:"loadingStrategy,omitempty"` +} + +// NewMetaV0alpha1SpecModule creates a new MetaV0alpha1SpecModule object. +func NewMetaV0alpha1SpecModule() *MetaV0alpha1SpecModule { + return &MetaV0alpha1SpecModule{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1SpecSignature struct { + Status MetaV0alpha1SpecSignatureStatus `json:"status"` + Type *MetaV0alpha1SpecSignatureType `json:"type,omitempty"` + Org *string `json:"org,omitempty"` +} + +// NewMetaV0alpha1SpecSignature creates a new MetaV0alpha1SpecSignature object. +func NewMetaV0alpha1SpecSignature() *MetaV0alpha1SpecSignature { + return &MetaV0alpha1SpecSignature{} +} + +// +k8s:openapi-gen=true +type MetaV0alpha1SpecAngular struct { + Detected bool `json:"detected"` +} + +// NewMetaV0alpha1SpecAngular creates a new MetaV0alpha1SpecAngular object. +func NewMetaV0alpha1SpecAngular() *MetaV0alpha1SpecAngular { + return &MetaV0alpha1SpecAngular{} +} + // +k8s:openapi-gen=true type MetaJSONDataType string @@ -464,6 +506,14 @@ const ( MetaIncludeRoleViewer MetaIncludeRole = "Viewer" ) +// +k8s:openapi-gen=true +type MetaSpecClass string + +const ( + MetaSpecClassCore MetaSpecClass = "core" + MetaSpecClassExternal MetaSpecClass = "external" +) + // +k8s:openapi-gen=true type MetaV0alpha1DependenciesPluginsType string @@ -472,3 +522,33 @@ const ( MetaV0alpha1DependenciesPluginsTypeDatasource MetaV0alpha1DependenciesPluginsType = "datasource" MetaV0alpha1DependenciesPluginsTypePanel MetaV0alpha1DependenciesPluginsType = "panel" ) + +// +k8s:openapi-gen=true +type MetaV0alpha1SpecModuleLoadingStrategy string + +const ( + MetaV0alpha1SpecModuleLoadingStrategyFetch MetaV0alpha1SpecModuleLoadingStrategy = "fetch" + MetaV0alpha1SpecModuleLoadingStrategyScript MetaV0alpha1SpecModuleLoadingStrategy = "script" +) + +// +k8s:openapi-gen=true +type MetaV0alpha1SpecSignatureStatus string + +const ( + MetaV0alpha1SpecSignatureStatusInternal MetaV0alpha1SpecSignatureStatus = "internal" + MetaV0alpha1SpecSignatureStatusValid MetaV0alpha1SpecSignatureStatus = "valid" + MetaV0alpha1SpecSignatureStatusInvalid MetaV0alpha1SpecSignatureStatus = "invalid" + MetaV0alpha1SpecSignatureStatusModified MetaV0alpha1SpecSignatureStatus = "modified" + MetaV0alpha1SpecSignatureStatusUnsigned MetaV0alpha1SpecSignatureStatus = "unsigned" +) + +// +k8s:openapi-gen=true +type MetaV0alpha1SpecSignatureType string + +const ( + MetaV0alpha1SpecSignatureTypeGrafana MetaV0alpha1SpecSignatureType = "grafana" + MetaV0alpha1SpecSignatureTypeCommercial MetaV0alpha1SpecSignatureType = "commercial" + MetaV0alpha1SpecSignatureTypeCommunity MetaV0alpha1SpecSignatureType = "community" + MetaV0alpha1SpecSignatureTypePrivate MetaV0alpha1SpecSignatureType = "private" + MetaV0alpha1SpecSignatureTypePrivateGlob MetaV0alpha1SpecSignatureType = "private-glob" +) diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go index 9ae14bcbdc5..51f5440eb92 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go @@ -4,21 +4,12 @@ package v0alpha1 // +k8s:openapi-gen=true type PluginSpec struct { - Id string `json:"id"` - Version string `json:"version"` - Url *string `json:"url,omitempty"` - Class PluginSpecClass `json:"class"` + Id string `json:"id"` + Version string `json:"version"` + Url *string `json:"url,omitempty"` } // NewPluginSpec creates a new PluginSpec object. func NewPluginSpec() *PluginSpec { return &PluginSpec{} } - -// +k8s:openapi-gen=true -type PluginSpecClass string - -const ( - PluginSpecClassCore PluginSpecClass = "core" - PluginSpecClassExternal PluginSpecClass = "external" -) diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 1a351eb2baf..7b87dcf0ea7 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -20,10 +20,10 @@ import ( ) var ( - rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"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"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"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"}}`) + rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"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"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"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"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"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"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"pluginJSON":{"$ref":"#/components/schemas/JSONData"}},"required":["pluginJSON"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"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"}}`) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"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"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"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"}}`) versionSchemaMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) diff --git a/apps/plugins/pkg/app/install/registrar.go b/apps/plugins/pkg/app/install/registrar.go index 3977fd88cf0..a900db1a68d 100644 --- a/apps/plugins/pkg/app/install/registrar.go +++ b/apps/plugins/pkg/app/install/registrar.go @@ -15,16 +15,6 @@ const ( PluginInstallSourceAnnotation = "plugins.grafana.app/install-source" ) -// Class represents the plugin class type in an unversioned internal format. -// This intentionally duplicates the versioned API type (PluginInstallSpecClass) to decouple -// internal code from API version changes, making it easier to support multiple API versions. -type Class = string - -const ( - ClassCore Class = "core" - ClassExternal Class = "external" -) - type Source = string const ( @@ -36,7 +26,6 @@ type PluginInstall struct { ID string Version string URL string - Class Class Source Source } @@ -57,7 +46,6 @@ func (p *PluginInstall) ToPluginInstallV0Alpha1(namespace string) *pluginsv0alph Id: p.ID, Version: p.Version, Url: url, - Class: pluginsv0alpha1.PluginSpecClass(p.Class), }, } } @@ -70,9 +58,6 @@ func (p *PluginInstall) ShouldUpdate(existing *pluginsv0alpha1.Plugin) bool { if existing.Spec.Version != update.Spec.Version { return true } - if existing.Spec.Class != update.Spec.Class { - return true // this should never really happen - } if !equalStringPointers(existing.Spec.Url, update.Spec.Url) { return true } diff --git a/apps/plugins/pkg/app/install/registrar_test.go b/apps/plugins/pkg/app/install/registrar_test.go index f1e0adbc508..6961acb98f8 100644 --- a/apps/plugins/pkg/app/install/registrar_test.go +++ b/apps/plugins/pkg/app/install/registrar_test.go @@ -26,14 +26,12 @@ func TestPluginInstall_ShouldUpdate(t *testing.T) { Spec: pluginsv0alpha1.PluginSpec{ Id: "plugin-1", Version: "1.0.0", - Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), }, } baseInstall := PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, } @@ -54,13 +52,6 @@ func TestPluginInstall_ShouldUpdate(t *testing.T) { }, expectUpdate: true, }, - { - name: "class differs", - modifyInstall: func(pi *PluginInstall) { - pi.Class = ClassCore - }, - expectUpdate: true, - }, { name: "url differs", modifyInstall: func(pi *PluginInstall) { @@ -109,7 +100,6 @@ func TestInstallRegistrar_Register(t *testing.T) { install: &PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, existingErr: errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1"), @@ -120,7 +110,6 @@ func TestInstallRegistrar_Register(t *testing.T) { install: &PluginInstall{ ID: "plugin-1", Version: "2.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, existing: &pluginsv0alpha1.Plugin{ @@ -135,7 +124,6 @@ func TestInstallRegistrar_Register(t *testing.T) { Spec: pluginsv0alpha1.PluginSpec{ Id: "plugin-1", Version: "1.0.0", - Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), }, }, expectedUpdates: 1, @@ -145,7 +133,6 @@ func TestInstallRegistrar_Register(t *testing.T) { install: &PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, existing: &pluginsv0alpha1.Plugin{ @@ -160,7 +147,6 @@ func TestInstallRegistrar_Register(t *testing.T) { Spec: pluginsv0alpha1.PluginSpec{ Id: "plugin-1", Version: "1.0.0", - Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), }, }, }, @@ -169,7 +155,6 @@ func TestInstallRegistrar_Register(t *testing.T) { install: &PluginInstall{ ID: "plugin-err", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, existingErr: errorsK8s.NewInternalError(errors.New("boom")), @@ -410,7 +395,6 @@ func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { install: PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, namespace: "org-1", @@ -424,7 +408,6 @@ func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { ID: "plugin-1", Version: "1.0.0", URL: "https://example.com/plugin.zip", - Class: ClassExternal, Source: SourcePluginStore, }, namespace: "org-1", @@ -433,25 +416,11 @@ func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { require.Equal(t, "https://example.com/plugin.zip", *p.Spec.Url) }, }, - { - name: "core class is mapped correctly", - install: PluginInstall{ - ID: "plugin-core", - Version: "2.0.0", - Class: ClassCore, - Source: SourcePluginStore, - }, - namespace: "org-2", - validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { - require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCore), p.Spec.Class) - }, - }, { name: "source annotation is set correctly", install: PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourceUnknown, }, namespace: "org-1", @@ -464,7 +433,6 @@ func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { install: PluginInstall{ ID: "my-plugin", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, namespace: "my-namespace", @@ -556,7 +524,6 @@ func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { ID: "plugin-1", Version: "1.0.0", URL: newURL, - Class: ClassExternal, Source: SourcePluginStore, }, existingURL: nil, @@ -568,7 +535,6 @@ func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { ID: "plugin-1", Version: "1.0.0", URL: "", - Class: ClassExternal, Source: SourcePluginStore, }, existingURL: &existingURL, @@ -580,7 +546,6 @@ func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { ID: "plugin-1", Version: "1.0.0", URL: "", - Class: ClassExternal, Source: SourcePluginStore, }, existingURL: nil, @@ -592,7 +557,6 @@ func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { ID: "plugin-1", Version: "1.0.0", URL: existingURL, - Class: ClassExternal, Source: SourcePluginStore, }, existingURL: &existingURL, @@ -614,7 +578,6 @@ func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) { Id: "plugin-1", Version: "1.0.0", Url: tt.existingURL, - Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), }, } @@ -670,7 +633,6 @@ func TestInstallRegistrar_Register_ErrorCases(t *testing.T) { install: &PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, setupClient: func(fc *fakePluginInstallClient) { @@ -688,7 +650,6 @@ func TestInstallRegistrar_Register_ErrorCases(t *testing.T) { install: &PluginInstall{ ID: "plugin-1", Version: "2.0.0", - Class: ClassExternal, Source: SourcePluginStore, }, setupClient: func(fc *fakePluginInstallClient) { @@ -705,7 +666,6 @@ func TestInstallRegistrar_Register_ErrorCases(t *testing.T) { Spec: pluginsv0alpha1.PluginSpec{ Id: "plugin-1", Version: "1.0.0", - Class: pluginsv0alpha1.PluginSpecClass(ClassExternal), }, }, nil } @@ -876,7 +836,6 @@ func TestInstallRegistrar_GetClientError(t *testing.T) { install := &PluginInstall{ ID: "plugin-1", Version: "1.0.0", - Class: ClassExternal, Source: SourcePluginStore, } diff --git a/apps/plugins/pkg/app/meta/catalog.go b/apps/plugins/pkg/app/meta/catalog.go index 6e6a47fa0bd..481db9b5947 100644 --- a/apps/plugins/pkg/app/meta/catalog.go +++ b/apps/plugins/pkg/app/meta/catalog.go @@ -10,8 +10,6 @@ import ( "time" "github.com/grafana/grafana-app-sdk/logging" - - pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" ) const ( @@ -87,45 +85,9 @@ func (p *CatalogProvider) GetMeta(ctx context.Context, pluginID, version string) return nil, fmt.Errorf("failed to decode response: %w", err) } + metaSpec := grafanaComPluginVersionMetaToMetaSpec(gcomMeta) return &Result{ - Meta: gcomMeta.JSON, + Meta: metaSpec, TTL: p.ttl, }, nil } - -// grafanaComPluginVersionMeta represents the response from grafana.com API -// GET /api/plugins/{pluginId}/versions/{version} -type grafanaComPluginVersionMeta struct { - PluginID string `json:"pluginSlug"` - Version string `json:"version"` - URL string `json:"url"` - Commit string `json:"commit"` - Description string `json:"description"` - Keywords []string `json:"keywords"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - JSON pluginsv0alpha1.MetaJSONData `json:"json"` - Readme string `json:"readme"` - Downloads int `json:"downloads"` - Verified bool `json:"verified"` - Status string `json:"status"` - StatusContext string `json:"statusContext"` - DownloadSlug string `json:"downloadSlug"` - SignatureType string `json:"signatureType"` - SignedByOrg string `json:"signedByOrg"` - SignedByOrgName string `json:"signedByOrgName"` - Packages struct { - Any struct { - Md5 string `json:"md5"` - Sha256 string `json:"sha256"` - PackageName string `json:"packageName"` - DownloadURL string `json:"downloadUrl"` - } `json:"any"` - } `json:"packages"` - Links []struct { - Rel string `json:"rel"` - Href string `json:"href"` - } `json:"links"` - AngularDetected bool `json:"angularDetected"` - Scopes []string `json:"scopes"` -} diff --git a/apps/plugins/pkg/app/meta/catalog_test.go b/apps/plugins/pkg/app/meta/catalog_test.go index 845afc7cd54..1dea816e002 100644 --- a/apps/plugins/pkg/app/meta/catalog_test.go +++ b/apps/plugins/pkg/app/meta/catalog_test.go @@ -49,7 +49,7 @@ func TestCatalogProvider_GetMeta(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, expectedMeta, result.Meta) + assert.Equal(t, expectedMeta, result.Meta.PluginJson) assert.Equal(t, defaultCatalogTTL, result.TTL) }) diff --git a/apps/plugins/pkg/app/meta/converter.go b/apps/plugins/pkg/app/meta/converter.go new file mode 100644 index 00000000000..28180a263bd --- /dev/null +++ b/apps/plugins/pkg/app/meta/converter.go @@ -0,0 +1,744 @@ +package meta + +import ( + "encoding/json" + "time" + + pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +// jsonDataToMetaJSONData converts a plugins.JSONData to a pluginsv0alpha1.MetaJSONData. +// nolint:gocyclo +func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSONData { + meta := pluginsv0alpha1.MetaJSONData{ + Id: jsonData.ID, + Name: jsonData.Name, + } + + // Map plugin type + switch jsonData.Type { + case plugins.TypeApp: + meta.Type = pluginsv0alpha1.MetaJSONDataTypeApp + case plugins.TypeDataSource: + meta.Type = pluginsv0alpha1.MetaJSONDataTypeDatasource + case plugins.TypePanel: + meta.Type = pluginsv0alpha1.MetaJSONDataTypePanel + case plugins.TypeRenderer: + meta.Type = pluginsv0alpha1.MetaJSONDataTypeRenderer + } + + // Map Info + meta.Info = pluginsv0alpha1.MetaInfo{ + Keywords: jsonData.Info.Keywords, + Logos: pluginsv0alpha1.MetaV0alpha1InfoLogos{ + Small: jsonData.Info.Logos.Small, + Large: jsonData.Info.Logos.Large, + }, + Updated: jsonData.Info.Updated, + Version: jsonData.Info.Version, + } + + if jsonData.Info.Description != "" { + meta.Info.Description = &jsonData.Info.Description + } + + if jsonData.Info.Author.Name != "" || jsonData.Info.Author.URL != "" { + author := &pluginsv0alpha1.MetaV0alpha1InfoAuthor{} + if jsonData.Info.Author.Name != "" { + author.Name = &jsonData.Info.Author.Name + } + if jsonData.Info.Author.URL != "" { + author.Url = &jsonData.Info.Author.URL + } + meta.Info.Author = author + } + + if len(jsonData.Info.Links) > 0 { + meta.Info.Links = make([]pluginsv0alpha1.MetaV0alpha1InfoLinks, 0, len(jsonData.Info.Links)) + for _, link := range jsonData.Info.Links { + v0Link := pluginsv0alpha1.MetaV0alpha1InfoLinks{} + if link.Name != "" { + v0Link.Name = &link.Name + } + if link.URL != "" { + v0Link.Url = &link.URL + } + meta.Info.Links = append(meta.Info.Links, v0Link) + } + } + + if len(jsonData.Info.Screenshots) > 0 { + meta.Info.Screenshots = make([]pluginsv0alpha1.MetaV0alpha1InfoScreenshots, 0, len(jsonData.Info.Screenshots)) + for _, screenshot := range jsonData.Info.Screenshots { + v0Screenshot := pluginsv0alpha1.MetaV0alpha1InfoScreenshots{} + if screenshot.Name != "" { + v0Screenshot.Name = &screenshot.Name + } + if screenshot.Path != "" { + v0Screenshot.Path = &screenshot.Path + } + meta.Info.Screenshots = append(meta.Info.Screenshots, v0Screenshot) + } + } + + // Map Dependencies + meta.Dependencies = pluginsv0alpha1.MetaDependencies{ + GrafanaDependency: jsonData.Dependencies.GrafanaDependency, + } + + if jsonData.Dependencies.GrafanaVersion != "" { + meta.Dependencies.GrafanaVersion = &jsonData.Dependencies.GrafanaVersion + } + + if len(jsonData.Dependencies.Plugins) > 0 { + meta.Dependencies.Plugins = make([]pluginsv0alpha1.MetaV0alpha1DependenciesPlugins, 0, len(jsonData.Dependencies.Plugins)) + for _, dep := range jsonData.Dependencies.Plugins { + var depType pluginsv0alpha1.MetaV0alpha1DependenciesPluginsType + switch dep.Type { + case "app": + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeApp + case "datasource": + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeDatasource + case "panel": + depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypePanel + } + meta.Dependencies.Plugins = append(meta.Dependencies.Plugins, pluginsv0alpha1.MetaV0alpha1DependenciesPlugins{ + Id: dep.ID, + Type: depType, + Name: dep.Name, + }) + } + } + + if len(jsonData.Dependencies.Extensions.ExposedComponents) > 0 { + meta.Dependencies.Extensions = &pluginsv0alpha1.MetaV0alpha1DependenciesExtensions{ + ExposedComponents: jsonData.Dependencies.Extensions.ExposedComponents, + } + } + + // Map optional boolean fields + if jsonData.Alerting { + meta.Alerting = &jsonData.Alerting + } + if jsonData.Annotations { + meta.Annotations = &jsonData.Annotations + } + if jsonData.AutoEnabled { + meta.AutoEnabled = &jsonData.AutoEnabled + } + if jsonData.Backend { + meta.Backend = &jsonData.Backend + } + if jsonData.BuiltIn { + meta.BuiltIn = &jsonData.BuiltIn + } + if jsonData.HideFromList { + meta.HideFromList = &jsonData.HideFromList + } + if jsonData.Logs { + meta.Logs = &jsonData.Logs + } + if jsonData.Metrics { + meta.Metrics = &jsonData.Metrics + } + if jsonData.MultiValueFilterOperators { + meta.MultiValueFilterOperators = &jsonData.MultiValueFilterOperators + } + if jsonData.Preload { + meta.Preload = &jsonData.Preload + } + if jsonData.SkipDataQuery { + meta.SkipDataQuery = &jsonData.SkipDataQuery + } + if jsonData.Streaming { + meta.Streaming = &jsonData.Streaming + } + if jsonData.Tracing { + meta.Tracing = &jsonData.Tracing + } + + // Map category + if jsonData.Category != "" { + var category pluginsv0alpha1.MetaJSONDataCategory + switch jsonData.Category { + case "tsdb": + category = pluginsv0alpha1.MetaJSONDataCategoryTsdb + case "logging": + category = pluginsv0alpha1.MetaJSONDataCategoryLogging + case "cloud": + category = pluginsv0alpha1.MetaJSONDataCategoryCloud + case "tracing": + category = pluginsv0alpha1.MetaJSONDataCategoryTracing + case "profiling": + category = pluginsv0alpha1.MetaJSONDataCategoryProfiling + case "sql": + category = pluginsv0alpha1.MetaJSONDataCategorySql + case "enterprise": + category = pluginsv0alpha1.MetaJSONDataCategoryEnterprise + case "iot": + category = pluginsv0alpha1.MetaJSONDataCategoryIot + case "other": + category = pluginsv0alpha1.MetaJSONDataCategoryOther + default: + category = pluginsv0alpha1.MetaJSONDataCategoryOther + } + meta.Category = &category + } + + // Map state + if jsonData.State != "" { + var state pluginsv0alpha1.MetaJSONDataState + switch jsonData.State { + case plugins.ReleaseStateAlpha: + state = pluginsv0alpha1.MetaJSONDataStateAlpha + case plugins.ReleaseStateBeta: + state = pluginsv0alpha1.MetaJSONDataStateBeta + default: + } + if state != "" { + meta.State = &state + } + } + + // Map executable + if jsonData.Executable != "" { + meta.Executable = &jsonData.Executable + } + + // Map QueryOptions + if len(jsonData.QueryOptions) > 0 { + queryOptions := &pluginsv0alpha1.MetaQueryOptions{} + if val, ok := jsonData.QueryOptions["maxDataPoints"]; ok { + queryOptions.MaxDataPoints = &val + } + if val, ok := jsonData.QueryOptions["minInterval"]; ok { + queryOptions.MinInterval = &val + } + if val, ok := jsonData.QueryOptions["cacheTimeout"]; ok { + queryOptions.CacheTimeout = &val + } + meta.QueryOptions = queryOptions + } + + // Map Includes + if len(jsonData.Includes) > 0 { + meta.Includes = make([]pluginsv0alpha1.MetaInclude, 0, len(jsonData.Includes)) + for _, include := range jsonData.Includes { + v0Include := pluginsv0alpha1.MetaInclude{} + if include.UID != "" { + v0Include.Uid = &include.UID + } + if include.Type != "" { + var includeType pluginsv0alpha1.MetaIncludeType + switch include.Type { + case "dashboard": + includeType = pluginsv0alpha1.MetaIncludeTypeDashboard + case "page": + includeType = pluginsv0alpha1.MetaIncludeTypePage + case "panel": + includeType = pluginsv0alpha1.MetaIncludeTypePanel + case "datasource": + includeType = pluginsv0alpha1.MetaIncludeTypeDatasource + } + v0Include.Type = &includeType + } + if include.Name != "" { + v0Include.Name = &include.Name + } + if include.Component != "" { + v0Include.Component = &include.Component + } + if include.Role != "" { + var role pluginsv0alpha1.MetaIncludeRole + switch include.Role { + case "Admin": + role = pluginsv0alpha1.MetaIncludeRoleAdmin + case "Editor": + role = pluginsv0alpha1.MetaIncludeRoleEditor + case "Viewer": + role = pluginsv0alpha1.MetaIncludeRoleViewer + } + v0Include.Role = &role + } + if include.Action != "" { + v0Include.Action = &include.Action + } + if include.Path != "" { + v0Include.Path = &include.Path + } + if include.AddToNav { + v0Include.AddToNav = &include.AddToNav + } + if include.DefaultNav { + v0Include.DefaultNav = &include.DefaultNav + } + if include.Icon != "" { + v0Include.Icon = &include.Icon + } + meta.Includes = append(meta.Includes, v0Include) + } + } + + // Map Routes + if len(jsonData.Routes) > 0 { + meta.Routes = make([]pluginsv0alpha1.MetaRoute, 0, len(jsonData.Routes)) + for _, route := range jsonData.Routes { + v0Route := pluginsv0alpha1.MetaRoute{} + if route.Path != "" { + v0Route.Path = &route.Path + } + if route.Method != "" { + v0Route.Method = &route.Method + } + if route.URL != "" { + v0Route.Url = &route.URL + } + if route.ReqRole != "" { + reqRole := string(route.ReqRole) + v0Route.ReqRole = &reqRole + } + if route.ReqAction != "" { + v0Route.ReqAction = &route.ReqAction + } + if len(route.Headers) > 0 { + headers := make([]string, 0, len(route.Headers)) + for _, header := range route.Headers { + headers = append(headers, header.Name+": "+header.Content) + } + v0Route.Headers = headers + } + if len(route.URLParams) > 0 { + v0Route.UrlParams = make([]pluginsv0alpha1.MetaV0alpha1RouteUrlParams, 0, len(route.URLParams)) + for _, param := range route.URLParams { + v0Param := pluginsv0alpha1.MetaV0alpha1RouteUrlParams{} + if param.Name != "" { + v0Param.Name = ¶m.Name + } + if param.Content != "" { + v0Param.Content = ¶m.Content + } + v0Route.UrlParams = append(v0Route.UrlParams, v0Param) + } + } + if route.TokenAuth != nil { + v0Route.TokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteTokenAuth{} + if route.TokenAuth.Url != "" { + v0Route.TokenAuth.Url = &route.TokenAuth.Url + } + if len(route.TokenAuth.Scopes) > 0 { + v0Route.TokenAuth.Scopes = route.TokenAuth.Scopes + } + if len(route.TokenAuth.Params) > 0 { + v0Route.TokenAuth.Params = make(map[string]interface{}) + for k, v := range route.TokenAuth.Params { + v0Route.TokenAuth.Params[k] = v + } + } + } + if route.JwtTokenAuth != nil { + v0Route.JwtTokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteJwtTokenAuth{} + if route.JwtTokenAuth.Url != "" { + v0Route.JwtTokenAuth.Url = &route.JwtTokenAuth.Url + } + if len(route.JwtTokenAuth.Scopes) > 0 { + v0Route.JwtTokenAuth.Scopes = route.JwtTokenAuth.Scopes + } + if len(route.JwtTokenAuth.Params) > 0 { + v0Route.JwtTokenAuth.Params = make(map[string]interface{}) + for k, v := range route.JwtTokenAuth.Params { + v0Route.JwtTokenAuth.Params[k] = v + } + } + } + if len(route.Body) > 0 { + var bodyMap map[string]interface{} + if err := json.Unmarshal(route.Body, &bodyMap); err == nil { + v0Route.Body = bodyMap + } + } + meta.Routes = append(meta.Routes, v0Route) + } + } + + // Map Extensions + if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 || + len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 { + extensions := &pluginsv0alpha1.MetaExtensions{} + + if len(jsonData.Extensions.AddedLinks) > 0 { + extensions.AddedLinks = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks, 0, len(jsonData.Extensions.AddedLinks)) + for _, link := range jsonData.Extensions.AddedLinks { + v0Link := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks{ + Targets: link.Targets, + Title: link.Title, + } + if link.Description != "" { + v0Link.Description = &link.Description + } + extensions.AddedLinks = append(extensions.AddedLinks, v0Link) + } + } + + if len(jsonData.Extensions.AddedComponents) > 0 { + extensions.AddedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents, 0, len(jsonData.Extensions.AddedComponents)) + for _, comp := range jsonData.Extensions.AddedComponents { + v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents{ + Targets: comp.Targets, + Title: comp.Title, + } + if comp.Description != "" { + v0Comp.Description = &comp.Description + } + extensions.AddedComponents = append(extensions.AddedComponents, v0Comp) + } + } + + if len(jsonData.Extensions.ExposedComponents) > 0 { + extensions.ExposedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents)) + for _, comp := range jsonData.Extensions.ExposedComponents { + v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents{ + Id: comp.Id, + } + if comp.Title != "" { + v0Comp.Title = &comp.Title + } + if comp.Description != "" { + v0Comp.Description = &comp.Description + } + extensions.ExposedComponents = append(extensions.ExposedComponents, v0Comp) + } + } + + if len(jsonData.Extensions.ExtensionPoints) > 0 { + extensions.ExtensionPoints = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints, 0, len(jsonData.Extensions.ExtensionPoints)) + for _, point := range jsonData.Extensions.ExtensionPoints { + v0Point := pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints{ + Id: point.Id, + } + if point.Title != "" { + v0Point.Title = &point.Title + } + if point.Description != "" { + v0Point.Description = &point.Description + } + extensions.ExtensionPoints = append(extensions.ExtensionPoints, v0Point) + } + } + + meta.Extensions = extensions + } + + // Map Roles + if len(jsonData.Roles) > 0 { + meta.Roles = make([]pluginsv0alpha1.MetaRole, 0, len(jsonData.Roles)) + for _, role := range jsonData.Roles { + v0Role := pluginsv0alpha1.MetaRole{ + Grants: role.Grants, + } + if role.Role.Name != "" || role.Role.Description != "" || len(role.Role.Permissions) > 0 { + v0RoleRole := &pluginsv0alpha1.MetaV0alpha1RoleRole{} + if role.Role.Name != "" { + v0RoleRole.Name = &role.Role.Name + } + if role.Role.Description != "" { + v0RoleRole.Description = &role.Role.Description + } + if len(role.Role.Permissions) > 0 { + v0RoleRole.Permissions = make([]pluginsv0alpha1.MetaV0alpha1RoleRolePermissions, 0, len(role.Role.Permissions)) + for _, perm := range role.Role.Permissions { + v0Perm := pluginsv0alpha1.MetaV0alpha1RoleRolePermissions{} + if perm.Action != "" { + v0Perm.Action = &perm.Action + } + if perm.Scope != "" { + v0Perm.Scope = &perm.Scope + } + v0RoleRole.Permissions = append(v0RoleRole.Permissions, v0Perm) + } + } + v0Role.Role = v0RoleRole + } + meta.Roles = append(meta.Roles, v0Role) + } + } + + // Map IAM + if jsonData.IAM != nil && len(jsonData.IAM.Permissions) > 0 { + iam := &pluginsv0alpha1.MetaIAM{ + Permissions: make([]pluginsv0alpha1.MetaV0alpha1IAMPermissions, 0, len(jsonData.IAM.Permissions)), + } + for _, perm := range jsonData.IAM.Permissions { + v0Perm := pluginsv0alpha1.MetaV0alpha1IAMPermissions{} + if perm.Action != "" { + v0Perm.Action = &perm.Action + } + if perm.Scope != "" { + v0Perm.Scope = &perm.Scope + } + iam.Permissions = append(iam.Permissions, v0Perm) + } + meta.Iam = iam + } + + return meta +} + +// pluginStorePluginToMeta converts a pluginstore.Plugin to a pluginsv0alpha1.MetaSpec. +// This is similar to pluginToPluginMetaSpec but works with the plugin store DTO. +// loadingStrategy and moduleHash are optional calculated values that can be provided. +func pluginStorePluginToMeta(plugin pluginstore.Plugin, loadingStrategy plugins.LoadingStrategy, moduleHash string) pluginsv0alpha1.MetaSpec { + metaSpec := pluginsv0alpha1.MetaSpec{ + PluginJson: jsonDataToMetaJSONData(plugin.JSONData), + } + + // Set Class - default to External if not specified + var c pluginsv0alpha1.MetaSpecClass + if plugin.Class == plugins.ClassCore { + c = pluginsv0alpha1.MetaSpecClassCore + } else { + c = pluginsv0alpha1.MetaSpecClassExternal + } + metaSpec.Class = c + + if plugin.Module != "" { + module := &pluginsv0alpha1.MetaV0alpha1SpecModule{ + Path: plugin.Module, + } + if moduleHash != "" { + module.Hash = &moduleHash + } + if loadingStrategy != "" { + var ls pluginsv0alpha1.MetaV0alpha1SpecModuleLoadingStrategy + switch loadingStrategy { + case plugins.LoadingStrategyFetch: + ls = pluginsv0alpha1.MetaV0alpha1SpecModuleLoadingStrategyFetch + case plugins.LoadingStrategyScript: + ls = pluginsv0alpha1.MetaV0alpha1SpecModuleLoadingStrategyScript + } + module.LoadingStrategy = &ls + } + metaSpec.Module = module + } + + if plugin.BaseURL != "" { + metaSpec.BaseURL = &plugin.BaseURL + } + + if plugin.Signature != "" { + signature := &pluginsv0alpha1.MetaV0alpha1SpecSignature{ + Status: convertSignatureStatus(plugin.Signature), + } + + if plugin.SignatureType != "" { + sigType := convertSignatureType(plugin.SignatureType) + signature.Type = &sigType + } + + if plugin.SignatureOrg != "" { + signature.Org = &plugin.SignatureOrg + } + + metaSpec.Signature = signature + } + + if len(plugin.Children) > 0 { + metaSpec.Children = plugin.Children + } + + metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ + Detected: plugin.Angular.Detected, + } + + if len(plugin.Translations) > 0 { + metaSpec.Translations = plugin.Translations + } + + return metaSpec +} + +// convertSignatureStatus converts plugins.SignatureStatus to pluginsv0alpha1.MetaV0alpha1SpecSignatureStatus. +func convertSignatureStatus(status plugins.SignatureStatus) pluginsv0alpha1.MetaV0alpha1SpecSignatureStatus { + switch status { + case plugins.SignatureStatusInternal: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusInternal + case plugins.SignatureStatusValid: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusValid + case plugins.SignatureStatusInvalid: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusInvalid + case plugins.SignatureStatusModified: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusModified + case plugins.SignatureStatusUnsigned: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusUnsigned + default: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusUnsigned + } +} + +// convertSignatureType converts plugins.SignatureType to pluginsv0alpha1.MetaV0alpha1SpecSignatureType. +func convertSignatureType(sigType plugins.SignatureType) pluginsv0alpha1.MetaV0alpha1SpecSignatureType { + switch sigType { + case plugins.SignatureTypeGrafana: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeGrafana + case plugins.SignatureTypeCommercial: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeCommercial + case plugins.SignatureTypeCommunity: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeCommunity + case plugins.SignatureTypePrivate: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypePrivate + case plugins.SignatureTypePrivateGlob: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypePrivateGlob + default: + return pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeGrafana + } +} + +// pluginToMetaSpec converts a fully loaded *plugins.Plugin to a pluginsv0alpha1.MetaSpec. +func pluginToMetaSpec(plugin *plugins.Plugin) pluginsv0alpha1.MetaSpec { + metaSpec := pluginsv0alpha1.MetaSpec{ + PluginJson: jsonDataToMetaJSONData(plugin.JSONData), + } + + // Set Class - default to External if not specified + var c pluginsv0alpha1.MetaSpecClass + if plugin.Class == plugins.ClassCore { + c = pluginsv0alpha1.MetaSpecClassCore + } else { + c = pluginsv0alpha1.MetaSpecClassExternal + } + metaSpec.Class = c + + // Set module information + if plugin.Module != "" { + module := &pluginsv0alpha1.MetaV0alpha1SpecModule{ + Path: plugin.Module, + } + + loadingStrategy := pluginsv0alpha1.MetaV0alpha1SpecModuleLoadingStrategyScript + module.LoadingStrategy = &loadingStrategy + + metaSpec.Module = module + } + + // Set BaseURL + if plugin.BaseURL != "" { + metaSpec.BaseURL = &plugin.BaseURL + } + + // Set signature information + signature := &pluginsv0alpha1.MetaV0alpha1SpecSignature{ + Status: convertSignatureStatus(plugin.Signature), + } + + if plugin.SignatureType != "" { + sigType := convertSignatureType(plugin.SignatureType) + signature.Type = &sigType + } + + if plugin.SignatureOrg != "" { + signature.Org = &plugin.SignatureOrg + } + + metaSpec.Signature = signature + + if len(plugin.Children) > 0 { + children := make([]string, 0, len(plugin.Children)) + for _, child := range plugin.Children { + children = append(children, child.ID) + } + metaSpec.Children = children + } + + metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ + Detected: plugin.Angular.Detected, + } + + if len(plugin.Translations) > 0 { + metaSpec.Translations = plugin.Translations + } + + return metaSpec +} + +// grafanaComPluginVersionMeta represents the response from grafana.com API +// GET /api/plugins/{pluginId}/versions/{version} +type grafanaComPluginVersionMeta struct { + PluginID string `json:"pluginSlug"` + Version string `json:"version"` + URL string `json:"url"` + Commit string `json:"commit"` + Description string `json:"description"` + Keywords []string `json:"keywords"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + JSON pluginsv0alpha1.MetaJSONData `json:"json"` + Readme string `json:"readme"` + Downloads int `json:"downloads"` + Verified bool `json:"verified"` + Status string `json:"status"` + StatusContext string `json:"statusContext"` + DownloadSlug string `json:"downloadSlug"` + SignatureType string `json:"signatureType"` + SignedByOrg string `json:"signedByOrg"` + SignedByOrgName string `json:"signedByOrgName"` + Packages struct { + Any struct { + Md5 string `json:"md5"` + Sha256 string `json:"sha256"` + PackageName string `json:"packageName"` + DownloadURL string `json:"downloadUrl"` + } `json:"any"` + } `json:"packages"` + Links []struct { + Rel string `json:"rel"` + Href string `json:"href"` + } `json:"links"` + AngularDetected bool `json:"angularDetected"` + Scopes []string `json:"scopes"` +} + +// grafanaComPluginVersionMetaToMetaSpec converts a grafanaComPluginVersionMeta to a pluginsv0alpha1.MetaSpec. +func grafanaComPluginVersionMetaToMetaSpec(gcomMeta grafanaComPluginVersionMeta) pluginsv0alpha1.MetaSpec { + metaSpec := pluginsv0alpha1.MetaSpec{ + PluginJson: gcomMeta.JSON, + Class: pluginsv0alpha1.MetaSpecClassExternal, + } + + if gcomMeta.SignatureType != "" { + signature := &pluginsv0alpha1.MetaV0alpha1SpecSignature{ + Status: pluginsv0alpha1.MetaV0alpha1SpecSignatureStatusValid, + } + + switch gcomMeta.SignatureType { + case "grafana": + sigType := pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeGrafana + signature.Type = &sigType + case "commercial": + sigType := pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeCommercial + signature.Type = &sigType + case "community": + sigType := pluginsv0alpha1.MetaV0alpha1SpecSignatureTypeCommunity + signature.Type = &sigType + case "private": + sigType := pluginsv0alpha1.MetaV0alpha1SpecSignatureTypePrivate + signature.Type = &sigType + case "private-glob": + sigType := pluginsv0alpha1.MetaV0alpha1SpecSignatureTypePrivateGlob + signature.Type = &sigType + } + + if gcomMeta.SignedByOrg != "" { + signature.Org = &gcomMeta.SignedByOrg + } + + metaSpec.Signature = signature + } + + // Set angular info + metaSpec.Angular = &pluginsv0alpha1.MetaV0alpha1SpecAngular{ + Detected: gcomMeta.AngularDetected, + } + + return metaSpec +} diff --git a/apps/plugins/pkg/app/meta/core.go b/apps/plugins/pkg/app/meta/core.go index e16f7a23db5..9fb167d5578 100644 --- a/apps/plugins/pkg/app/meta/core.go +++ b/apps/plugins/pkg/app/meta/core.go @@ -2,7 +2,6 @@ package meta import ( "context" - "encoding/json" "errors" "os" "path/filepath" @@ -13,7 +12,15 @@ import ( pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + pluginsLoader "github.com/grafana/grafana/pkg/plugins/manager/loader" + "github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap" + "github.com/grafana/grafana/pkg/plugins/manager/pipeline/discovery" + "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" + "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" + "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/sources" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" ) const ( @@ -23,9 +30,10 @@ const ( // CoreProvider retrieves plugin metadata for core plugins. type CoreProvider struct { mu sync.RWMutex - loadedPlugins map[string]pluginsv0alpha1.MetaJSONData + loadedPlugins map[string]pluginsv0alpha1.MetaSpec initialized bool ttl time.Duration + loader pluginsLoader.Service } // NewCoreProvider creates a new CoreProvider for core plugins. @@ -35,9 +43,13 @@ func NewCoreProvider() *CoreProvider { // NewCoreProviderWithTTL creates a new CoreProvider with a custom TTL. func NewCoreProviderWithTTL(ttl time.Duration) *CoreProvider { + cfg := &config.PluginManagementCfg{ + Features: config.Features{}, + } return &CoreProvider{ - loadedPlugins: make(map[string]pluginsv0alpha1.MetaJSONData), + loadedPlugins: make(map[string]pluginsv0alpha1.MetaSpec), ttl: ttl, + loader: createLoader(cfg), } } @@ -76,9 +88,9 @@ func (p *CoreProvider) GetMeta(ctx context.Context, pluginID, _ string) (*Result p.initialized = true } - if meta, found := p.loadedPlugins[pluginID]; found { + if spec, found := p.loadedPlugins[pluginID]; found { return &Result{ - Meta: meta, + Meta: spec, TTL: p.ttl, }, nil } @@ -86,8 +98,8 @@ func (p *CoreProvider) GetMeta(ctx context.Context, pluginID, _ string) (*Result return nil, ErrMetaNotFound } -// loadPlugins discovers and caches all core plugins. -// Returns an error if the static root path cannot be found or if plugin discovery fails. +// loadPlugins discovers and caches all core plugins by fully loading them. +// Returns an error if the static root path cannot be found or if plugin loading fails. // This error will be handled gracefully by GetMeta, which will return ErrMetaNotFound // to allow other providers to handle the request. func (p *CoreProvider) loadPlugins(ctx context.Context) error { @@ -108,496 +120,51 @@ func (p *CoreProvider) loadPlugins(ctx context.Context) error { panelPath := filepath.Join(staticRootPath, "app", "plugins", "panel") src := sources.NewLocalSource(plugins.ClassCore, []string{datasourcePath, panelPath}) - ps, err := src.Discover(ctx) + loadedPlugins, err := p.loader.Load(ctx, src) if err != nil { return err } - if len(ps) == 0 { - logging.DefaultLogger.Warn("CoreProvider: no core plugins found during discovery") + if len(loadedPlugins) == 0 { + logging.DefaultLogger.Warn("CoreProvider: no core plugins found during loading") return nil } - for _, bundle := range ps { - meta := jsonDataToMetaJSONData(bundle.Primary.JSONData) - p.loadedPlugins[bundle.Primary.JSONData.ID] = meta + for _, plugin := range loadedPlugins { + metaSpec := pluginToMetaSpec(plugin) + p.loadedPlugins[plugin.ID] = metaSpec } return nil } -// jsonDataToMetaJSONData converts a plugins.JSONData to a pluginsv0alpha1.MetaJSONData. -// nolint:gocyclo -func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSONData { - meta := pluginsv0alpha1.MetaJSONData{ - Id: jsonData.ID, - Name: jsonData.Name, - } - - // Map plugin type - switch jsonData.Type { - case plugins.TypeApp: - meta.Type = pluginsv0alpha1.MetaJSONDataTypeApp - case plugins.TypeDataSource: - meta.Type = pluginsv0alpha1.MetaJSONDataTypeDatasource - case plugins.TypePanel: - meta.Type = pluginsv0alpha1.MetaJSONDataTypePanel - case plugins.TypeRenderer: - meta.Type = pluginsv0alpha1.MetaJSONDataTypeRenderer - } - - // Map Info - meta.Info = pluginsv0alpha1.MetaInfo{ - Keywords: jsonData.Info.Keywords, - Logos: pluginsv0alpha1.MetaV0alpha1InfoLogos{ - Small: jsonData.Info.Logos.Small, - Large: jsonData.Info.Logos.Large, +// createLoader creates a loader service configured for core plugins. +func createLoader(cfg *config.PluginManagementCfg) pluginsLoader.Service { + d := discovery.New(cfg, discovery.Opts{ + FilterFuncs: []discovery.FilterFunc{ + // Allow all plugin types for core plugins }, - Updated: jsonData.Info.Updated, - Version: jsonData.Info.Version, - } + }) + b := bootstrap.New(cfg, bootstrap.Opts{ + DecorateFuncs: []bootstrap.DecorateFunc{}, // no decoration required for metadata + }) + v := validation.New(cfg, validation.Opts{ + ValidateFuncs: []validation.ValidateFunc{ + // Skip validation for core plugins - they're trusted + }, + }) + i := initialization.New(cfg, initialization.Opts{ + InitializeFuncs: []initialization.InitializeFunc{ + // Skip initialization - we only need metadata, not running plugins + }, + }) + t, _ := termination.New(cfg, termination.Opts{ + TerminateFuncs: []termination.TerminateFunc{ + // No termination needed for metadata-only loading + }, + }) - if jsonData.Info.Description != "" { - meta.Info.Description = &jsonData.Info.Description - } + et := pluginerrs.ProvideErrorTracker() - if jsonData.Info.Author.Name != "" || jsonData.Info.Author.URL != "" { - author := &pluginsv0alpha1.MetaV0alpha1InfoAuthor{} - if jsonData.Info.Author.Name != "" { - author.Name = &jsonData.Info.Author.Name - } - if jsonData.Info.Author.URL != "" { - author.Url = &jsonData.Info.Author.URL - } - meta.Info.Author = author - } - - if len(jsonData.Info.Links) > 0 { - meta.Info.Links = make([]pluginsv0alpha1.MetaV0alpha1InfoLinks, 0, len(jsonData.Info.Links)) - for _, link := range jsonData.Info.Links { - v0Link := pluginsv0alpha1.MetaV0alpha1InfoLinks{} - if link.Name != "" { - v0Link.Name = &link.Name - } - if link.URL != "" { - v0Link.Url = &link.URL - } - meta.Info.Links = append(meta.Info.Links, v0Link) - } - } - - if len(jsonData.Info.Screenshots) > 0 { - meta.Info.Screenshots = make([]pluginsv0alpha1.MetaV0alpha1InfoScreenshots, 0, len(jsonData.Info.Screenshots)) - for _, screenshot := range jsonData.Info.Screenshots { - v0Screenshot := pluginsv0alpha1.MetaV0alpha1InfoScreenshots{} - if screenshot.Name != "" { - v0Screenshot.Name = &screenshot.Name - } - if screenshot.Path != "" { - v0Screenshot.Path = &screenshot.Path - } - meta.Info.Screenshots = append(meta.Info.Screenshots, v0Screenshot) - } - } - - // Map Dependencies - meta.Dependencies = pluginsv0alpha1.MetaDependencies{ - GrafanaDependency: jsonData.Dependencies.GrafanaDependency, - } - - if jsonData.Dependencies.GrafanaVersion != "" { - meta.Dependencies.GrafanaVersion = &jsonData.Dependencies.GrafanaVersion - } - - if len(jsonData.Dependencies.Plugins) > 0 { - meta.Dependencies.Plugins = make([]pluginsv0alpha1.MetaV0alpha1DependenciesPlugins, 0, len(jsonData.Dependencies.Plugins)) - for _, dep := range jsonData.Dependencies.Plugins { - var depType pluginsv0alpha1.MetaV0alpha1DependenciesPluginsType - switch dep.Type { - case "app": - depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeApp - case "datasource": - depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypeDatasource - case "panel": - depType = pluginsv0alpha1.MetaV0alpha1DependenciesPluginsTypePanel - } - meta.Dependencies.Plugins = append(meta.Dependencies.Plugins, pluginsv0alpha1.MetaV0alpha1DependenciesPlugins{ - Id: dep.ID, - Type: depType, - Name: dep.Name, - }) - } - } - - if len(jsonData.Dependencies.Extensions.ExposedComponents) > 0 { - meta.Dependencies.Extensions = &pluginsv0alpha1.MetaV0alpha1DependenciesExtensions{ - ExposedComponents: jsonData.Dependencies.Extensions.ExposedComponents, - } - } - - // Map optional boolean fields - if jsonData.Alerting { - meta.Alerting = &jsonData.Alerting - } - if jsonData.Annotations { - meta.Annotations = &jsonData.Annotations - } - if jsonData.AutoEnabled { - meta.AutoEnabled = &jsonData.AutoEnabled - } - if jsonData.Backend { - meta.Backend = &jsonData.Backend - } - if jsonData.BuiltIn { - meta.BuiltIn = &jsonData.BuiltIn - } - if jsonData.HideFromList { - meta.HideFromList = &jsonData.HideFromList - } - if jsonData.Logs { - meta.Logs = &jsonData.Logs - } - if jsonData.Metrics { - meta.Metrics = &jsonData.Metrics - } - if jsonData.MultiValueFilterOperators { - meta.MultiValueFilterOperators = &jsonData.MultiValueFilterOperators - } - if jsonData.Preload { - meta.Preload = &jsonData.Preload - } - if jsonData.SkipDataQuery { - meta.SkipDataQuery = &jsonData.SkipDataQuery - } - if jsonData.Streaming { - meta.Streaming = &jsonData.Streaming - } - if jsonData.Tracing { - meta.Tracing = &jsonData.Tracing - } - - // Map category - if jsonData.Category != "" { - var category pluginsv0alpha1.MetaJSONDataCategory - switch jsonData.Category { - case "tsdb": - category = pluginsv0alpha1.MetaJSONDataCategoryTsdb - case "logging": - category = pluginsv0alpha1.MetaJSONDataCategoryLogging - case "cloud": - category = pluginsv0alpha1.MetaJSONDataCategoryCloud - case "tracing": - category = pluginsv0alpha1.MetaJSONDataCategoryTracing - case "profiling": - category = pluginsv0alpha1.MetaJSONDataCategoryProfiling - case "sql": - category = pluginsv0alpha1.MetaJSONDataCategorySql - case "enterprise": - category = pluginsv0alpha1.MetaJSONDataCategoryEnterprise - case "iot": - category = pluginsv0alpha1.MetaJSONDataCategoryIot - case "other": - category = pluginsv0alpha1.MetaJSONDataCategoryOther - default: - category = pluginsv0alpha1.MetaJSONDataCategoryOther - } - meta.Category = &category - } - - // Map state - if jsonData.State != "" { - var state pluginsv0alpha1.MetaJSONDataState - switch jsonData.State { - case plugins.ReleaseStateAlpha: - state = pluginsv0alpha1.MetaJSONDataStateAlpha - case plugins.ReleaseStateBeta: - state = pluginsv0alpha1.MetaJSONDataStateBeta - default: - } - if state != "" { - meta.State = &state - } - } - - // Map executable - if jsonData.Executable != "" { - meta.Executable = &jsonData.Executable - } - - // Map QueryOptions - if len(jsonData.QueryOptions) > 0 { - queryOptions := &pluginsv0alpha1.MetaQueryOptions{} - if val, ok := jsonData.QueryOptions["maxDataPoints"]; ok { - queryOptions.MaxDataPoints = &val - } - if val, ok := jsonData.QueryOptions["minInterval"]; ok { - queryOptions.MinInterval = &val - } - if val, ok := jsonData.QueryOptions["cacheTimeout"]; ok { - queryOptions.CacheTimeout = &val - } - meta.QueryOptions = queryOptions - } - - // Map Includes - if len(jsonData.Includes) > 0 { - meta.Includes = make([]pluginsv0alpha1.MetaInclude, 0, len(jsonData.Includes)) - for _, include := range jsonData.Includes { - v0Include := pluginsv0alpha1.MetaInclude{} - if include.UID != "" { - v0Include.Uid = &include.UID - } - if include.Type != "" { - var includeType pluginsv0alpha1.MetaIncludeType - switch include.Type { - case "dashboard": - includeType = pluginsv0alpha1.MetaIncludeTypeDashboard - case "page": - includeType = pluginsv0alpha1.MetaIncludeTypePage - case "panel": - includeType = pluginsv0alpha1.MetaIncludeTypePanel - case "datasource": - includeType = pluginsv0alpha1.MetaIncludeTypeDatasource - } - v0Include.Type = &includeType - } - if include.Name != "" { - v0Include.Name = &include.Name - } - if include.Component != "" { - v0Include.Component = &include.Component - } - if include.Role != "" { - var role pluginsv0alpha1.MetaIncludeRole - switch include.Role { - case "Admin": - role = pluginsv0alpha1.MetaIncludeRoleAdmin - case "Editor": - role = pluginsv0alpha1.MetaIncludeRoleEditor - case "Viewer": - role = pluginsv0alpha1.MetaIncludeRoleViewer - } - v0Include.Role = &role - } - if include.Action != "" { - v0Include.Action = &include.Action - } - if include.Path != "" { - v0Include.Path = &include.Path - } - if include.AddToNav { - v0Include.AddToNav = &include.AddToNav - } - if include.DefaultNav { - v0Include.DefaultNav = &include.DefaultNav - } - if include.Icon != "" { - v0Include.Icon = &include.Icon - } - meta.Includes = append(meta.Includes, v0Include) - } - } - - // Map Routes - if len(jsonData.Routes) > 0 { - meta.Routes = make([]pluginsv0alpha1.MetaRoute, 0, len(jsonData.Routes)) - for _, route := range jsonData.Routes { - v0Route := pluginsv0alpha1.MetaRoute{} - if route.Path != "" { - v0Route.Path = &route.Path - } - if route.Method != "" { - v0Route.Method = &route.Method - } - if route.URL != "" { - v0Route.Url = &route.URL - } - if route.ReqRole != "" { - reqRole := string(route.ReqRole) - v0Route.ReqRole = &reqRole - } - if route.ReqAction != "" { - v0Route.ReqAction = &route.ReqAction - } - if len(route.Headers) > 0 { - headers := make([]string, 0, len(route.Headers)) - for _, header := range route.Headers { - headers = append(headers, header.Name+": "+header.Content) - } - v0Route.Headers = headers - } - if len(route.URLParams) > 0 { - v0Route.UrlParams = make([]pluginsv0alpha1.MetaV0alpha1RouteUrlParams, 0, len(route.URLParams)) - for _, param := range route.URLParams { - v0Param := pluginsv0alpha1.MetaV0alpha1RouteUrlParams{} - if param.Name != "" { - v0Param.Name = ¶m.Name - } - if param.Content != "" { - v0Param.Content = ¶m.Content - } - v0Route.UrlParams = append(v0Route.UrlParams, v0Param) - } - } - if route.TokenAuth != nil { - v0Route.TokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteTokenAuth{} - if route.TokenAuth.Url != "" { - v0Route.TokenAuth.Url = &route.TokenAuth.Url - } - if len(route.TokenAuth.Scopes) > 0 { - v0Route.TokenAuth.Scopes = route.TokenAuth.Scopes - } - if len(route.TokenAuth.Params) > 0 { - v0Route.TokenAuth.Params = make(map[string]interface{}) - for k, v := range route.TokenAuth.Params { - v0Route.TokenAuth.Params[k] = v - } - } - } - if route.JwtTokenAuth != nil { - v0Route.JwtTokenAuth = &pluginsv0alpha1.MetaV0alpha1RouteJwtTokenAuth{} - if route.JwtTokenAuth.Url != "" { - v0Route.JwtTokenAuth.Url = &route.JwtTokenAuth.Url - } - if len(route.JwtTokenAuth.Scopes) > 0 { - v0Route.JwtTokenAuth.Scopes = route.JwtTokenAuth.Scopes - } - if len(route.JwtTokenAuth.Params) > 0 { - v0Route.JwtTokenAuth.Params = make(map[string]interface{}) - for k, v := range route.JwtTokenAuth.Params { - v0Route.JwtTokenAuth.Params[k] = v - } - } - } - if len(route.Body) > 0 { - var bodyMap map[string]interface{} - if err := json.Unmarshal(route.Body, &bodyMap); err == nil { - v0Route.Body = bodyMap - } - } - meta.Routes = append(meta.Routes, v0Route) - } - } - - // Map Extensions - if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 || - len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 { - extensions := &pluginsv0alpha1.MetaExtensions{} - - if len(jsonData.Extensions.AddedLinks) > 0 { - extensions.AddedLinks = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks, 0, len(jsonData.Extensions.AddedLinks)) - for _, link := range jsonData.Extensions.AddedLinks { - v0Link := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedLinks{ - Targets: link.Targets, - Title: link.Title, - } - if link.Description != "" { - v0Link.Description = &link.Description - } - extensions.AddedLinks = append(extensions.AddedLinks, v0Link) - } - } - - if len(jsonData.Extensions.AddedComponents) > 0 { - extensions.AddedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents, 0, len(jsonData.Extensions.AddedComponents)) - for _, comp := range jsonData.Extensions.AddedComponents { - v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedComponents{ - Targets: comp.Targets, - Title: comp.Title, - } - if comp.Description != "" { - v0Comp.Description = &comp.Description - } - extensions.AddedComponents = append(extensions.AddedComponents, v0Comp) - } - } - - if len(jsonData.Extensions.ExposedComponents) > 0 { - extensions.ExposedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents)) - for _, comp := range jsonData.Extensions.ExposedComponents { - v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents{ - Id: comp.Id, - } - if comp.Title != "" { - v0Comp.Title = &comp.Title - } - if comp.Description != "" { - v0Comp.Description = &comp.Description - } - extensions.ExposedComponents = append(extensions.ExposedComponents, v0Comp) - } - } - - if len(jsonData.Extensions.ExtensionPoints) > 0 { - extensions.ExtensionPoints = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints, 0, len(jsonData.Extensions.ExtensionPoints)) - for _, point := range jsonData.Extensions.ExtensionPoints { - v0Point := pluginsv0alpha1.MetaV0alpha1ExtensionsExtensionPoints{ - Id: point.Id, - } - if point.Title != "" { - v0Point.Title = &point.Title - } - if point.Description != "" { - v0Point.Description = &point.Description - } - extensions.ExtensionPoints = append(extensions.ExtensionPoints, v0Point) - } - } - - meta.Extensions = extensions - } - - // Map Roles - if len(jsonData.Roles) > 0 { - meta.Roles = make([]pluginsv0alpha1.MetaRole, 0, len(jsonData.Roles)) - for _, role := range jsonData.Roles { - v0Role := pluginsv0alpha1.MetaRole{ - Grants: role.Grants, - } - if role.Role.Name != "" || role.Role.Description != "" || len(role.Role.Permissions) > 0 { - v0RoleRole := &pluginsv0alpha1.MetaV0alpha1RoleRole{} - if role.Role.Name != "" { - v0RoleRole.Name = &role.Role.Name - } - if role.Role.Description != "" { - v0RoleRole.Description = &role.Role.Description - } - if len(role.Role.Permissions) > 0 { - v0RoleRole.Permissions = make([]pluginsv0alpha1.MetaV0alpha1RoleRolePermissions, 0, len(role.Role.Permissions)) - for _, perm := range role.Role.Permissions { - v0Perm := pluginsv0alpha1.MetaV0alpha1RoleRolePermissions{} - if perm.Action != "" { - v0Perm.Action = &perm.Action - } - if perm.Scope != "" { - v0Perm.Scope = &perm.Scope - } - v0RoleRole.Permissions = append(v0RoleRole.Permissions, v0Perm) - } - } - v0Role.Role = v0RoleRole - } - meta.Roles = append(meta.Roles, v0Role) - } - } - - // Map IAM - if jsonData.IAM != nil && len(jsonData.IAM.Permissions) > 0 { - iam := &pluginsv0alpha1.MetaIAM{ - Permissions: make([]pluginsv0alpha1.MetaV0alpha1IAMPermissions, 0, len(jsonData.IAM.Permissions)), - } - for _, perm := range jsonData.IAM.Permissions { - v0Perm := pluginsv0alpha1.MetaV0alpha1IAMPermissions{} - if perm.Action != "" { - v0Perm.Action = &perm.Action - } - if perm.Scope != "" { - v0Perm.Scope = &perm.Scope - } - iam.Permissions = append(iam.Permissions, v0Perm) - } - meta.Iam = iam - } - - return meta + return pluginsLoader.New(cfg, d, b, v, i, t, et) } diff --git a/apps/plugins/pkg/app/meta/core_test.go b/apps/plugins/pkg/app/meta/core_test.go index d5235c9d120..d6f51057fab 100644 --- a/apps/plugins/pkg/app/meta/core_test.go +++ b/apps/plugins/pkg/app/meta/core_test.go @@ -22,10 +22,12 @@ func TestCoreProvider_GetMeta(t *testing.T) { t.Run("returns cached plugin when available", func(t *testing.T) { provider := NewCoreProvider() - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider.mu.Lock() @@ -58,10 +60,12 @@ func TestCoreProvider_GetMeta(t *testing.T) { t.Run("ignores version parameter", func(t *testing.T) { provider := NewCoreProvider() - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider.mu.Lock() @@ -81,10 +85,12 @@ func TestCoreProvider_GetMeta(t *testing.T) { customTTL := 2 * time.Hour provider := NewCoreProviderWithTTL(customTTL) - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider.mu.Lock() @@ -226,8 +232,8 @@ func TestCoreProvider_loadPlugins(t *testing.T) { if loaded { result, err := provider.GetMeta(ctx, "test-datasource", "1.0.0") require.NoError(t, err) - assert.Equal(t, "test-datasource", result.Meta.Id) - assert.Equal(t, "Test Datasource", result.Meta.Name) + assert.Equal(t, "test-datasource", result.Meta.PluginJson.Id) + assert.Equal(t, "Test Datasource", result.Meta.PluginJson.Name) } }) } diff --git a/apps/plugins/pkg/app/meta/local.go b/apps/plugins/pkg/app/meta/local.go new file mode 100644 index 00000000000..2c699520cfc --- /dev/null +++ b/apps/plugins/pkg/app/meta/local.go @@ -0,0 +1,53 @@ +package meta + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" +) + +const ( + defaultLocalTTL = 1 * time.Hour +) + +// PluginAssetsCalculator is an interface for calculating plugin asset information. +// LocalProvider requires this to calculate loading strategy and module hash. +type PluginAssetsCalculator interface { + LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy + ModuleHash(ctx context.Context, p pluginstore.Plugin) string +} + +// LocalProvider retrieves plugin metadata for locally installed plugins. +// It uses the plugin store to access plugins that have already been loaded. +type LocalProvider struct { + store pluginstore.Store + pluginAssets PluginAssetsCalculator +} + +// NewLocalProvider creates a new LocalProvider for locally installed plugins. +// pluginAssets is required for calculating loading strategy and module hash. +func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider { + return &LocalProvider{ + store: pluginStore, + pluginAssets: pluginAssets, + } +} + +// GetMeta retrieves plugin metadata for locally installed plugins. +func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (*Result, error) { + plugin, exists := p.store.Plugin(ctx, pluginID) + if !exists { + return nil, ErrMetaNotFound + } + + loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin) + moduleHash := p.pluginAssets.ModuleHash(ctx, plugin) + + spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash) + return &Result{ + Meta: spec, + TTL: defaultLocalTTL, + }, nil +} diff --git a/apps/plugins/pkg/app/meta/manager.go b/apps/plugins/pkg/app/meta/manager.go index 7e99d0cc197..d8cd1f554c2 100644 --- a/apps/plugins/pkg/app/meta/manager.go +++ b/apps/plugins/pkg/app/meta/manager.go @@ -16,7 +16,7 @@ const ( // cachedMeta represents a cached metadata entry with expiration time type cachedMeta struct { - meta pluginsv0alpha1.MetaJSONData + meta pluginsv0alpha1.MetaSpec ttl time.Duration expiresAt time.Time } @@ -84,7 +84,7 @@ func (pm *ProviderManager) GetMeta(ctx context.Context, pluginID, version string if err == nil { // Don't cache results with a zero TTL if result.TTL == 0 { - continue + return result, nil } pm.cacheMu.Lock() diff --git a/apps/plugins/pkg/app/meta/manager_test.go b/apps/plugins/pkg/app/meta/manager_test.go index 31a75424a23..c9a8a9f3a5f 100644 --- a/apps/plugins/pkg/app/meta/manager_test.go +++ b/apps/plugins/pkg/app/meta/manager_test.go @@ -35,10 +35,12 @@ func TestProviderManager_GetMeta(t *testing.T) { ctx := context.Background() t.Run("returns cached result when available and not expired", func(t *testing.T) { - cachedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + cachedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider := &mockProvider{ @@ -60,8 +62,10 @@ func TestProviderManager_GetMeta(t *testing.T) { provider.getMetaFunc = func(ctx context.Context, pluginID, version string) (*Result, error) { return &Result{ - Meta: pluginsv0alpha1.MetaJSONData{Id: "different"}, - TTL: time.Hour, + Meta: pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{Id: "different"}, + }, + TTL: time.Hour, }, nil } @@ -73,10 +77,12 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("fetches from provider when not cached", func(t *testing.T) { - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } expectedTTL := 2 * time.Hour @@ -107,19 +113,16 @@ func TestProviderManager_GetMeta(t *testing.T) { assert.Equal(t, expectedTTL, cached.ttl) }) - t.Run("does not cache result with zero TTL and tries next provider", func(t *testing.T) { - zeroTTLMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Zero TTL Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, - } - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + t.Run("does not cache result with zero TTL", func(t *testing.T) { + zeroTTLMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Zero TTL Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } - provider1 := &mockProvider{ + provider := &mockProvider{ getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) { return &Result{ Meta: zeroTTLMeta, @@ -127,37 +130,30 @@ func TestProviderManager_GetMeta(t *testing.T) { }, nil }, } - provider2 := &mockProvider{ - getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) { - return &Result{ - Meta: expectedMeta, - TTL: time.Hour, - }, nil - }, - } - pm := NewProviderManager(provider1, provider2) + pm := NewProviderManager(provider) result, err := pm.GetMeta(ctx, "test-plugin", "1.0.0") require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, expectedMeta, result.Meta) + assert.Equal(t, zeroTTLMeta, result.Meta) + assert.Equal(t, time.Duration(0), result.TTL) pm.cacheMu.RLock() - cached, exists := pm.cache["test-plugin:1.0.0"] + _, exists := pm.cache["test-plugin:1.0.0"] pm.cacheMu.RUnlock() - assert.True(t, exists) - assert.Equal(t, expectedMeta, cached.meta) - assert.Equal(t, time.Hour, cached.ttl) + assert.False(t, exists, "zero TTL results should not be cached") }) t.Run("tries next provider when first returns ErrMetaNotFound", func(t *testing.T) { - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider1 := &mockProvider{ @@ -229,15 +225,19 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("skips expired cache entries", func(t *testing.T) { - expiredMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Expired Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expiredMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Expired Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } - expectedMeta := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Test Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Test Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } callCount := 0 @@ -272,15 +272,19 @@ func TestProviderManager_GetMeta(t *testing.T) { }) t.Run("uses first successful provider", func(t *testing.T) { - expectedMeta1 := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Provider 1 Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta1 := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Provider 1 Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } - expectedMeta2 := pluginsv0alpha1.MetaJSONData{ - Id: "test-plugin", - Name: "Provider 2 Plugin", - Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + expectedMeta2 := pluginsv0alpha1.MetaSpec{ + PluginJson: pluginsv0alpha1.MetaJSONData{ + Id: "test-plugin", + Name: "Provider 2 Plugin", + Type: pluginsv0alpha1.MetaJSONDataTypeDatasource, + }, } provider1 := &mockProvider{ @@ -331,9 +335,9 @@ func TestProviderManager_Run(t *testing.T) { func TestProviderManager_cleanupExpired(t *testing.T) { t.Run("removes expired entries", func(t *testing.T) { - validMeta := pluginsv0alpha1.MetaJSONData{Id: "valid"} - expiredMeta1 := pluginsv0alpha1.MetaJSONData{Id: "expired1"} - expiredMeta2 := pluginsv0alpha1.MetaJSONData{Id: "expired2"} + validMeta := pluginsv0alpha1.MetaSpec{PluginJson: pluginsv0alpha1.MetaJSONData{Id: "valid"}} + expiredMeta1 := pluginsv0alpha1.MetaSpec{PluginJson: pluginsv0alpha1.MetaJSONData{Id: "expired1"}} + expiredMeta2 := pluginsv0alpha1.MetaSpec{PluginJson: pluginsv0alpha1.MetaJSONData{Id: "expired2"}} provider := &mockProvider{ getMetaFunc: func(ctx context.Context, pluginID, version string) (*Result, error) { diff --git a/apps/plugins/pkg/app/meta/provider.go b/apps/plugins/pkg/app/meta/provider.go index 818c0da9dc5..3c01361d656 100644 --- a/apps/plugins/pkg/app/meta/provider.go +++ b/apps/plugins/pkg/app/meta/provider.go @@ -14,7 +14,7 @@ var ( // Result contains plugin metadata along with its recommended TTL. type Result struct { - Meta pluginsv0alpha1.MetaJSONData + Meta pluginsv0alpha1.MetaSpec TTL time.Duration } diff --git a/apps/plugins/pkg/app/storage.go b/apps/plugins/pkg/app/storage.go index f2dd2ac98d5..01e62b9c4ec 100644 --- a/apps/plugins/pkg/app/storage.go +++ b/apps/plugins/pkg/app/storage.go @@ -121,8 +121,19 @@ func (s *MetaStorage) List(ctx context.Context, options *internalversion.ListOpt continue } - pluginMeta := createMetaFromMetaJSONData(result.Meta, plugin.Name, plugin.Namespace) - metaItems = append(metaItems, *pluginMeta) + pluginMeta := pluginsv0alpha1.Meta{ + ObjectMeta: metav1.ObjectMeta{ + Name: plugin.Name, + Namespace: plugin.Namespace, + }, + Spec: result.Meta, + } + pluginMeta.SetGroupVersionKind(schema.GroupVersionKind{ + Group: pluginsv0alpha1.APIGroup, + Version: pluginsv0alpha1.APIVersion, + Kind: pluginsv0alpha1.MetaKind().Kind(), + }) + metaItems = append(metaItems, pluginMeta) } list := &pluginsv0alpha1.MetaList{ @@ -169,27 +180,18 @@ func (s *MetaStorage) Get(ctx context.Context, name string, options *metav1.GetO return nil, apierrors.NewInternalError(fmt.Errorf("failed to fetch plugin metadata: %w", err)) } - return createMetaFromMetaJSONData(result.Meta, name, ns.Value), nil -} - -// createMetaFromMetaJSONData creates a Meta k8s object from MetaJSONData and plugin metadata. -func createMetaFromMetaJSONData(pluginJSON pluginsv0alpha1.MetaJSONData, name, namespace string) *pluginsv0alpha1.Meta { pluginMeta := &pluginsv0alpha1.Meta{ ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: pluginsv0alpha1.MetaSpec{ - PluginJSON: pluginJSON, + Name: plugin.Name, + Namespace: plugin.Namespace, }, + Spec: result.Meta, } - - // Set the GroupVersionKind pluginMeta.SetGroupVersionKind(schema.GroupVersionKind{ Group: pluginsv0alpha1.APIGroup, Version: pluginsv0alpha1.APIVersion, Kind: pluginsv0alpha1.MetaKind().Kind(), }) - return pluginMeta + return pluginMeta, nil } diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index c82b90c4dcc..62f3f1bf49c 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/go-github/v70 v70.0.0 github.com/google/uuid v1.6.0 github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f + github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 @@ -28,6 +29,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -54,13 +56,20 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-app-sdk v0.48.7 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -68,6 +77,7 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index 39f1b7f2064..8b5733639be 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -14,6 +14,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -59,6 +61,8 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -98,6 +102,13 @@ github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ= github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -110,6 +121,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE= github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -118,14 +131,22 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -150,6 +171,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= 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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/apps/provisioning/kinds/manifest.cue b/apps/provisioning/kinds/manifest.cue index d0d751dd62b..b0202cb47ce 100644 --- a/apps/provisioning/kinds/manifest.cue +++ b/apps/provisioning/kinds/manifest.cue @@ -1,9 +1,10 @@ package repository manifest: { - appName: "provisioning" - groupOverride: "provisioning.grafana.app" - kinds: [ + appName: "provisioning" + groupOverride: "provisioning.grafana.app" + preferredVersion: "v0alpha1" + kinds: [ repository, connection ] diff --git a/apps/provisioning/kinds/repository.cue b/apps/provisioning/kinds/repository.cue index fec3d1f9e20..629d7a62885 100644 --- a/apps/provisioning/kinds/repository.cue +++ b/apps/provisioning/kinds/repository.cue @@ -80,7 +80,7 @@ repository: { // Enabled must be saved as true before any sync job will run enabled: bool // Where values should be saved - target: "unified" | "legacy" + target: "instance" | "folder" // When non-zero, the sync will run periodically intervalSeconds?: int } diff --git a/apps/provisioning/pkg/apis/manifestdata/provisioning_manifest.go b/apps/provisioning/pkg/apis/manifestdata/provisioning_manifest.go new file mode 100644 index 00000000000..c13e0389c11 --- /dev/null +++ b/apps/provisioning/pkg/apis/manifestdata/provisioning_manifest.go @@ -0,0 +1,92 @@ +// +// This file is generated by grafana-app-sdk +// DO NOT EDIT +// + +package manifestdata + +import ( + "fmt" + "strings" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "k8s.io/apimachinery/pkg/runtime" +) + +var appManifestData = app.ManifestData{ + AppName: "provisioning", + Group: "provisioning.grafana.app", + PreferredVersion: "v0alpha1", + Versions: []app.ManifestVersion{}, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("provisioning") +} + +var kindVersionToGoType = map[string]resource.Kind{} + +// 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 +} + +var customRouteToGoResponseType = map[string]any{} + +// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. +// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. +// If there is no association for the provided kind, version, custom route path, and method, exists will return false. +// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) +func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoParamsType = map[string]runtime.Object{} + +func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoRequestBodyType = map[string]any{} + +func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +type GoTypeAssociator struct{} + +func NewGoTypeAssociator() *GoTypeAssociator { + return &GoTypeAssociator{} +} + +func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { + return ManifestGoTypeAssociator(kind, version) +} +func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { + return ManifestCustomRouteQueryAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go index 2e7700ac4a2..100c75b080b 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go @@ -136,9 +136,6 @@ type ExportJobOptions struct { } type MigrateJobOptions struct { - // Preserve history (if possible) - History bool `json:"history,omitempty"` - // Message to use when committing the changes in a single commit Message string `json:"message,omitempty"` } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go index 3031bde77cb..1aab919c9f6 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/settings.go @@ -9,11 +9,6 @@ import ( type RepositoryViewList struct { metav1.TypeMeta `json:",inline"` - // The backend is using legacy storage - // FIXME: Not sure where this should be exposed... but we need it somewhere - // The UI should force the onboarding workflow when this is true - LegacyStorage bool `json:"legacyStorage,omitempty"` - // The valid targets (can disable instance or folder types) AllowedTargets []SyncTargetType `json:"allowedTargets,omitempty"` diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index aeee1d407e1..a96fa2d8a6b 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -1495,13 +1495,6 @@ func schema_pkg_apis_provisioning_v0alpha1_MigrateJobOptions(ref common.Referenc SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "history": { - SchemaProps: spec.SchemaProps{ - Description: "Preserve history (if possible)", - Type: []string{"boolean"}, - Format: "", - }, - }, "message": { SchemaProps: spec.SchemaProps{ Description: "Message to use when committing the changes in a single commit", @@ -2119,13 +2112,6 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryViewList(ref common.Referen Format: "", }, }, - "legacyStorage": { - SchemaProps: spec.SchemaProps{ - Description: "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", - Type: []string{"boolean"}, - Format: "", - }, - }, "allowedTargets": { SchemaProps: spec.SchemaProps{ Description: "The valid targets (can disable instance or folder types)", diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/migratejoboptions.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/migratejoboptions.go index aee0b05fa3a..7f689c19585 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/migratejoboptions.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/migratejoboptions.go @@ -7,7 +7,6 @@ package v0alpha1 // MigrateJobOptionsApplyConfiguration represents a declarative configuration of the MigrateJobOptions type for use // with apply. type MigrateJobOptionsApplyConfiguration struct { - History *bool `json:"history,omitempty"` Message *string `json:"message,omitempty"` } @@ -17,14 +16,6 @@ func MigrateJobOptions() *MigrateJobOptionsApplyConfiguration { return &MigrateJobOptionsApplyConfiguration{} } -// WithHistory sets the History field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the History field is set to the value of the last call. -func (b *MigrateJobOptionsApplyConfiguration) WithHistory(value bool) *MigrateJobOptionsApplyConfiguration { - b.History = &value - return b -} - // WithMessage sets the Message field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Message field is set to the value of the last call. diff --git a/apps/provisioning/pkg/jobs/validator_test.go b/apps/provisioning/pkg/jobs/validator_test.go index fdd29598ebc..172e1929e1e 100644 --- a/apps/provisioning/pkg/jobs/validator_test.go +++ b/apps/provisioning/pkg/jobs/validator_test.go @@ -384,8 +384,7 @@ func TestValidateJob(t *testing.T) { Action: provisioning.JobActionMigrate, Repository: "test-repo", Migrate: &provisioning.MigrateJobOptions{ - History: true, - Message: "Migrate from legacy", + Message: "Migrate from unified", }, }, }, diff --git a/apps/provisioning/pkg/repository/git/repository.go b/apps/provisioning/pkg/repository/git/repository.go index 82d5ca6bd1d..bac3bd6d0a6 100644 --- a/apps/provisioning/pkg/repository/git/repository.go +++ b/apps/provisioning/pkg/repository/git/repository.go @@ -238,6 +238,8 @@ func (r *gitRepository) Read(ctx context.Context, filePath, ref string) (*reposi // Check if the path represents a directory if safepath.IsDir(filePath) { + // Strip trailing slash for git tree lookup to avoid empty path components + finalPath = strings.TrimSuffix(finalPath, "/") tree, err := r.client.GetTreeByPath(ctx, commit.Tree, finalPath) if err != nil { if errors.Is(err, nanogit.ErrObjectNotFound) { diff --git a/docs/sources/setup-grafana/installation/debian/index.md b/docs/sources/setup-grafana/installation/debian/index.md index 067bcb29f6a..900c4a4b9d1 100644 --- a/docs/sources/setup-grafana/installation/debian/index.md +++ b/docs/sources/setup-grafana/installation/debian/index.md @@ -46,7 +46,7 @@ Complete the following steps to install Grafana from the APT repository: 1. Install the prerequisite packages: ```bash - sudo apt-get install -y apt-transport-https software-properties-common wget + sudo apt-get install -y apt-transport-https wget ``` 1. Import the GPG key: diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cdbeabcc87b..dfa69ae64f4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2397,11 +2397,6 @@ "count": 1 } }, - "public/app/features/datasources/components/DataSourceLoadError.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/datasources/components/DataSourcePluginState.tsx": { "no-restricted-syntax": { "count": 3 diff --git a/go.work.sum b/go.work.sum index 1358a13e9f5..73813d12650 100644 --- a/go.work.sum +++ b/go.work.sum @@ -13,6 +13,7 @@ cel.dev/expr v0.16.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= cel.dev/expr v0.19.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA= cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= cloud.google.com/go v0.121.1/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= @@ -329,6 +330,8 @@ github.com/KimMachineGun/automemlimit v0.7.1 h1:QcG/0iCOLChjfUweIMC3YL5Xy9C3VBeN github.com/KimMachineGun/automemlimit v0.7.1/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= @@ -410,6 +413,7 @@ github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+ye github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= +github.com/at-wat/mqtt-go v0.19.4/go.mod h1:AsiWc9kqVOhqq7LzUeWT/AkKUBfx3Sw5cEe8lc06fqA= github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= @@ -489,6 +493,8 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/baidubce/bce-sdk-go v0.9.188 h1:8MA7ewe4VpX01uYl7Kic6ZvfIReUFdSKbY46ZqlQM7U= @@ -527,6 +533,10 @@ github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/centrifugal/centrifuge v0.37.2/go.mod h1:aj4iRJGhzi3SlL8iUtVezxway1Xf8g+hmNQkLLO7sS8= github.com/centrifugal/protocol v0.16.2/go.mod h1:Q7OpS/8HMXDnL7f9DpNx24IhG96MP88WPpVTTCdrokI= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= @@ -562,6 +572,7 @@ github.com/coder/quartz v0.1.0 h1:cLL+0g5l7xTf6ordRnUMMiZtRE8Sq5LxpghS63vEXrQ= github.com/coder/quartz v0.1.0/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k= github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE= @@ -684,6 +695,7 @@ github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6 github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/efficientgo/tools/core v0.0.0-20220225185207-fe763185946b h1:ZHiD4/yE4idlbqvAO6iYCOYRzOMRpxkW+FKasRA3tsQ= github.com/efficientgo/tools/core v0.0.0-20220225185207-fe763185946b/go.mod h1:OmVcnJopJL8d3X3sSXTiypGoUSgFq1aDGmlrdi9dn/M= github.com/elastic/elastic-transport-go/v8 v8.6.1 h1:h2jQRqH6eLGiBSN4eZbQnJLtL4bC5b4lfVFRjw2R4e4= @@ -716,6 +728,7 @@ github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQK github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= @@ -780,6 +793,7 @@ github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5 github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -862,10 +876,13 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I= github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= +github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38= github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= github.com/grafana/dskit v0.0.0-20250818234656-8ff9c6532e85/go.mod h1:kImsvJ1xnmeT9Z6StK+RdEKLzlpzBsKwJbEQfmBJdFs= @@ -914,6 +931,7 @@ github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagI github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana-plugin-sdk-go v0.279.0/go.mod h1:/7oGN6Z7DGTGaLHhgIYrRr6Wvmdsb3BLw5hL4Kbjy88= github.com/grafana/grafana-plugin-sdk-go v0.280.0/go.mod h1:Z15Wiq3c4I0tzHYrLYpOqrO8u3+2RJ+HN2Q9uiZTILA= +github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA= github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c= @@ -958,11 +976,13 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= @@ -1353,6 +1373,7 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= @@ -1383,6 +1404,7 @@ github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+u github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= @@ -1394,6 +1416,8 @@ github.com/sagikazarmark/crypt v0.6.0 h1:REOEXCs/NFY/1jOCEouMuT4zEniE5YoXbvpC5X/ github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/samber/slog-common v0.18.1 h1:c0EipD/nVY9HG5shgm/XAs67mgpWDMF+MmtptdJNCkQ= @@ -1599,6 +1623,7 @@ go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5queth go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/collector v0.121.0/go.mod h1:M4TlnmkjIgishm2DNCk9K3hMKTmAsY9w8cNFsp9EchM= go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg= go.opentelemetry.io/collector/client v1.29.0/go.mod h1:LCUoEV2KCTKA1i+/txZaGsSPVWUcqeOV6wCfNsAippE= @@ -1885,6 +1910,7 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -1930,6 +1956,7 @@ golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mod v0.6.0-dev.0.20220818022119-ed83ed61efb9/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= @@ -1941,6 +1968,7 @@ golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= @@ -2035,6 +2063,7 @@ golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= @@ -2089,6 +2118,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= @@ -2120,7 +2150,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= @@ -2145,6 +2177,7 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2188,12 +2221,14 @@ k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.26.2/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.26.2/go.mod h1:GHcozwXgXsPuOJ28EnQ/jXEM9QeG6HT22YxSNmpYNh8= k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU= k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= @@ -2202,6 +2237,7 @@ k8s.io/code-generator v0.34.3 h1:6ipJKsJZZ9q21BO8I2jEj4OLN3y8/1n4aihKN0xKmQk= k8s.io/code-generator v0.34.3/go.mod h1:oW73UPYpGLsbRN8Ozkhd6ZzkF8hzFCiYmvEuWZDroI4= k8s.io/component-base v0.26.2/go.mod h1:DxbuIe9M3IZPRxPIzhch2m1eT7uFrSBJUBuVCQEBivs= k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= +k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= k8s.io/cri-api v0.27.1/go.mod h1:+Ts/AVYbIo04S86XbTD73UPp/DkTiYxtsFeOFEu32L0= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6 h1:4s3/R4+OYYYUKptXPhZKjQ04WJ6EhQQVFdjOFvCazDk= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q= @@ -2214,6 +2250,8 @@ k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kms v0.34.1/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM= +k8s.io/kube-aggregator v0.34.1/go.mod h1:RU8j+5ERfp0h+gIvWtxRPfsa5nK7rboDm8RST8BJfYQ= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= @@ -2261,6 +2299,7 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ih sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/package.json b/package.json index 1151e8ee235..e148217cac3 100644 --- a/package.json +++ b/package.json @@ -295,8 +295,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.49.0", - "@grafana/scenes-react": "6.49.0", + "@grafana/scenes": "6.50.0", + "@grafana/scenes-react": "6.50.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 3bb583d7cb4..be22503f3bf 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1585,8 +1585,6 @@ export type DeleteJobOptions = { resources?: ResourceRef[]; }; export type MigrateJobOptions = { - /** Preserve history (if possible) */ - history?: boolean; /** Message to use when committing the changes in a single commit */ message?: string; }; @@ -2047,8 +2045,6 @@ export type RepositoryViewList = { items: RepositoryView[]; /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; - /** The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true */ - legacyStorage?: boolean; }; export type ManagerStats = { /** Manager identity */ diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 63c36639e25..6027b566764 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -664,6 +664,7 @@ export { type DataSourceGetTagKeysOptions, type DataSourceGetTagValuesOptions, type DataSourceGetDrilldownsApplicabilityOptions, + type DataSourceGetRecommendedDrilldownsOptions, type MetadataInspectorProps, type LegacyMetricFindQueryOptions, type QueryEditorProps, @@ -681,6 +682,7 @@ export { type QueryHint, type MetricFindValue, type DrilldownsApplicability, + type DrilldownRecommendation, type DataSourceJsonData, type DataSourceSettings, type DataSourceInstanceSettings, diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 538e7a051e8..f50e126a5c7 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -313,6 +313,13 @@ abstract class DataSourceApi< options?: DataSourceGetDrilldownsApplicabilityOptions ): Promise; + /** + * Get recommended drilldowns for a dashboard + */ + getRecommendedDrilldowns?( + options?: DataSourceGetRecommendedDrilldownsOptions + ): Promise; + /** * Get tag keys for adhoc filters */ @@ -398,13 +405,9 @@ abstract class DataSourceApi< } /** - * Options argument to DataSourceAPI.getTagKeys + * Base options shared across datasource filtering operations. */ -export interface DataSourceGetTagKeysOptions { - /** - * The other existing filters or base filters. New in v10.3 - */ - filters: AdHocVariableFilter[]; +export interface DataSourceFilteringRequestOptions { /** * Context time range. New in v10.3 */ @@ -413,21 +416,27 @@ export interface DataSourceGetTagKeysOptions + extends DataSourceFilteringRequestOptions { + /** + * The other existing filters or base filters. New in v10.3 + */ + filters: AdHocVariableFilter[]; +} + /** * Options argument to DataSourceAPI.getTagValues */ -export interface DataSourceGetTagValuesOptions { +export interface DataSourceGetTagValuesOptions + extends DataSourceFilteringRequestOptions { key: string; /** * The other existing filters or base filters. New in v10.3 */ filters: AdHocVariableFilter[]; - /** - * Context time range. New in v10.3 - */ - timeRange?: TimeRange; - queries?: TQuery[]; - scopes?: Scope[] | undefined; } export interface MetadataInspectorProps< @@ -646,12 +655,22 @@ export interface MetricFindValue { properties?: Record; } -export interface DataSourceGetDrilldownsApplicabilityOptions { +export interface DataSourceGetDrilldownsApplicabilityOptions + extends DataSourceFilteringRequestOptions { + filters?: AdHocVariableFilter[]; + groupByKeys?: string[]; +} + +export interface DataSourceGetRecommendedDrilldownsOptions + extends DataSourceFilteringRequestOptions { + dashboardUid?: string; + filters?: AdHocVariableFilter[]; + groupByKeys?: string[]; +} + +export interface DrilldownRecommendation { filters?: AdHocVariableFilter[]; groupByKeys?: string[]; - timeRange?: TimeRange; - queries?: TQuery[]; - scopes?: Scope[] | undefined; } export interface DrilldownsApplicability { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 04e4862f2ad..51eada8c120 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -373,6 +373,10 @@ export interface FeatureToggles { */ unlimitedLayoutsNesting?: boolean; /** + * Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables + */ + drilldownRecommendations?: boolean; + /** * Enables viewing non-applicable drilldowns on a panel level */ perPanelNonApplicableDrilldowns?: boolean; diff --git a/packages/grafana-e2e-selectors/src/types/selectors.ts b/packages/grafana-e2e-selectors/src/types/selectors.ts index 036d6f36d4d..dbce9c93bd7 100644 --- a/packages/grafana-e2e-selectors/src/types/selectors.ts +++ b/packages/grafana-e2e-selectors/src/types/selectors.ts @@ -15,7 +15,7 @@ export type FunctionSelector = (id: string) => string; export type FunctionSelectorTwoArgs = (arg1: string, arg2: string) => string; /** - * A function selector without argument + * A function selector without arguments */ export type CssSelector = () => string; diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index 81c7cd31f9d..b94f0514879 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -81,7 +81,15 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe return validation.Validate(info, requester, validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"), validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"), - validation.MustBeEmptyValidator(info.ApiUrl, "API URL")) + validation.MustBeEmptyValidator(info.ApiUrl, "API URL"), + loginPromptValidator) +} + +func loginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error { + if info.UseRefreshToken && !slices.Contains([]string{"", "consent"}, info.LoginPrompt) { + return ssosettings.ErrInvalidOAuthConfig("If provided, login_prompt must be set to consent when use_refresh_token is enabled.") + } + return nil } func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { diff --git a/pkg/login/social/connectors/google_oauth_test.go b/pkg/login/social/connectors/google_oauth_test.go index d330c39d78d..620c448013b 100644 --- a/pkg/login/social/connectors/google_oauth_test.go +++ b/pkg/login/social/connectors/google_oauth_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -871,6 +873,39 @@ func TestSocialGoogle_Validate(t *testing.T) { }, wantErr: ssosettings.ErrBaseInvalidOAuthConfig, }, + { + name: "fails if use_refresh_token is enabled and login prompt is neither empty or 'consent'", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "use_refresh_token": "true", + "login_prompt": "login", + }, + }, + wantErr: ssosettings.ErrBaseInvalidOAuthConfig, + }, + { + name: "succeeds if use_refresh_token is enabled and login prompt is empty", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "use_refresh_token": "true", + "login_prompt": "", + }, + }, + wantErr: nil, + }, + { + name: "succeeds if use_refresh_token is enabled and login prompt is consent", + settings: ssoModels.SSOSettings{ + Settings: map[string]any{ + "client_id": "client-id", + "use_refresh_token": "true", + "login_prompt": "consent", + }, + }, + wantErr: nil, + }, } for _, tc := range testCases { @@ -886,7 +921,13 @@ func TestSocialGoogle_Validate(t *testing.T) { require.ErrorIs(t, err, tc.wantErr) return } - require.NoError(t, err) + + if err != nil { + var e errutil.Error + require.True(t, errors.As(err, &e)) + require.NoError(t, e, "expected no error, got %v", e.PublicMessage) + return + } }) } } @@ -1024,3 +1065,102 @@ func TestIsHDAllowed(t *testing.T) { }) } } + +func TestSocialGoogle_AuthCodeURL(t *testing.T) { + testCases := []struct { + name string + info *social.OAuthInfo + opts []oauth2.AuthCodeOption + state string + wantURL *url.URL + }{ + { + name: "should return the correct auth code URL", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + AuthUrl: "https://example.com/auth", + LoginPrompt: "login", + Scopes: []string{"openid", "email", "profile"}, + }, + state: "test-state", + opts: []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("extra_param", "extra_value"), + }, + wantURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/auth", + RawQuery: url.Values{ + "state": {"test-state"}, + "prompt": {"login"}, + "response_type": {"code"}, + "client_id": {"client-id"}, + "redirect_uri": {"/login/google"}, + "scope": {"openid email profile"}, + "extra_param": {"extra_value"}, + }.Encode(), + }, + }, + { + name: "should add access type offline and approval force if use refresh token is enabled", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + AuthUrl: "https://example.com/auth", + Scopes: []string{"openid", "email", "profile"}, + UseRefreshToken: true, + }, + state: "test-state", + wantURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/auth", + RawQuery: url.Values{ + "state": {"test-state"}, + "prompt": {"consent"}, + "response_type": {"code"}, + "client_id": {"client-id"}, + "redirect_uri": {"/login/google"}, + "scope": {"openid email profile"}, + "access_type": {"offline"}, + }.Encode(), + }, + }, + { + name: "should override configured login prompt if use refresh token is enabled", + info: &social.OAuthInfo{ + ClientId: "client-id", + ClientSecret: "client-secret", + AuthUrl: "https://example.com/auth", + Scopes: []string{"openid", "email", "profile"}, + UseRefreshToken: true, + }, + state: "test-state", + wantURL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/auth", + RawQuery: url.Values{ + "state": {"test-state"}, + "prompt": {"consent"}, + "response_type": {"code"}, + "client_id": {"client-id"}, + "redirect_uri": {"/login/google"}, + "scope": {"openid email profile"}, + "access_type": {"offline"}, + }.Encode(), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, ssosettingstests.NewFakeService(), featuremgmt.WithFeatures()) + gotURL := s.AuthCodeURL(tc.state, tc.opts...) + parsedURL, err := url.Parse(gotURL) + require.NoError(t, err) + require.EqualValues(t, tc.wantURL, parsedURL) + }) + } +} diff --git a/pkg/login/social/connectors/social_base.go b/pkg/login/social/connectors/social_base.go index 0db4d81baeb..3ecbf8e334e 100644 --- a/pkg/login/social/connectors/social_base.go +++ b/pkg/login/social/connectors/social_base.go @@ -91,7 +91,11 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { if s.info.LoginPrompt != "" { promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt) - opts = append(opts, promptOpt) + + // Prepend the prompt option to the opts slice to ensure it is applied last. + // This is necessary in case the caller provides an option that overrides the prompt, + // such as `oauth2.ApprovalForce`. + opts = append([]oauth2.AuthCodeOption{promptOpt}, opts...) } return s.Config.AuthCodeURL(state, opts...) diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index 1651a126ffe..416eb5c0e3f 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -85,7 +85,6 @@ func RunRepoController(deps server.OperatorDependencies) error { resourceLister, controllerCfg.clients, jobs, - nil, // dualwrite -- standalone operator assumes it is backed by unified storage healthChecker, statusPatcher, deps.Registerer, diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index 0fb1217ec70..e6845322516 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -3,11 +3,16 @@ package bootstrap import ( "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/semconv" ) // Bootstrapper is responsible for the Bootstrap stage of the plugin loader pipeline. @@ -34,6 +39,7 @@ type Bootstrap struct { constructStep ConstructFunc decorateSteps []DecorateFunc log log.Logger + tracer trace.Tracer } type Opts struct { @@ -55,14 +61,21 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap { constructStep: opts.ConstructFunc, decorateSteps: opts.DecorateFuncs, log: log.New("plugins.bootstrap"), + tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap"), } } // Bootstrap will execute the Construct and Decorate steps of the Bootstrap stage. func (b *Bootstrap) Bootstrap(ctx context.Context, src plugins.PluginSource, found *plugins.FoundBundle) ([]*plugins.Plugin, error) { + pluginClass := src.PluginClass(ctx) + ctx, span := b.tracer.Start(ctx, "bootstrap.Bootstrap", trace.WithAttributes( + semconv.PluginSourceClass(pluginClass), + )) + defer span.End() + ps, err := b.constructStep(ctx, src, found) if err != nil { - return nil, err + return nil, tracing.Error(span, err) } if len(b.decorateSteps) == 0 { @@ -76,7 +89,7 @@ func (b *Bootstrap) Bootstrap(ctx context.Context, src plugins.PluginSource, fou ip, err = decorate(ctx, p) if err != nil { b.log.Error("Could not decorate plugin", "pluginId", p.ID, "error", err) - return nil, err + return nil, tracing.Error(span, err) } } bootstrappedPlugins = append(bootstrappedPlugins, ip) diff --git a/pkg/plugins/manager/pipeline/discovery/discovery.go b/pkg/plugins/manager/pipeline/discovery/discovery.go index e624fbb3fcc..e5bdc50dd62 100644 --- a/pkg/plugins/manager/pipeline/discovery/discovery.go +++ b/pkg/plugins/manager/pipeline/discovery/discovery.go @@ -3,6 +3,11 @@ package discovery import ( "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" @@ -26,6 +31,7 @@ type FilterFunc func(ctx context.Context, class plugins.Class, bundles []*plugin type Discovery struct { filterSteps []FilterFunc log log.Logger + tracer trace.Tracer } type Opts struct { @@ -41,29 +47,37 @@ func New(_ *config.PluginManagementCfg, opts Opts) *Discovery { return &Discovery{ filterSteps: opts.FilterFuncs, log: log.New("plugins.discovery"), + tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/discovery"), } } // Discover will execute the Filter step of the Discovery stage. func (d *Discovery) Discover(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) { + pluginClass := src.PluginClass(ctx) + ctx, span := d.tracer.Start(ctx, "discovery.Discover", trace.WithAttributes( + attribute.String("grafana.plugins.class", string(pluginClass)), + )) + defer span.End() + ctxLogger := d.log.FromContext(ctx) + // Use the source's own Discover method found, err := src.Discover(ctx) if err != nil { - d.log.Warn("Discovery source failed", "class", src.PluginClass(ctx), "error", err) - return nil, err + ctxLogger.Warn("Discovery source failed", "class", pluginClass, "error", err) + return nil, tracing.Error(span, err) } - d.log.Debug("Found plugins", "class", src.PluginClass(ctx), "count", len(found)) + ctxLogger.Debug("Found plugins", "class", pluginClass, "count", len(found)) // Apply filtering steps result := found for _, filter := range d.filterSteps { result, err = filter(ctx, src.PluginClass(ctx), result) if err != nil { - return nil, err + return nil, tracing.Error(span, err) } } - d.log.Debug("Discovery complete", "class", src.PluginClass(ctx), "found", len(found), "filtered", len(result)) + ctxLogger.Debug("Discovery complete", "class", pluginClass, "found", len(found), "filtered", len(result)) return result, nil } diff --git a/pkg/plugins/manager/pipeline/initialization/initialization.go b/pkg/plugins/manager/pipeline/initialization/initialization.go index 52a638a00f0..4319f4811a7 100644 --- a/pkg/plugins/manager/pipeline/initialization/initialization.go +++ b/pkg/plugins/manager/pipeline/initialization/initialization.go @@ -3,9 +3,14 @@ package initialization import ( "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/semconv" ) // Initializer is responsible for the Initialization stage of the plugin loader pipeline. @@ -20,6 +25,7 @@ type Initialize struct { cfg *config.PluginManagementCfg initializeSteps []InitializeFunc log log.Logger + tracer trace.Tracer } type Opts struct { @@ -36,11 +42,17 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Initialize { cfg: cfg, initializeSteps: opts.InitializeFuncs, log: log.New("plugins.initialization"), + tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization"), } } // Initialize will execute the Initialize steps of the Initialization stage. func (i *Initialize) Initialize(ctx context.Context, ps *plugins.Plugin) (*plugins.Plugin, error) { + ctx, span := i.tracer.Start(ctx, "initialization.Initialize", trace.WithAttributes( + semconv.GrafanaPluginId(ps.ID), + )) + defer span.End() + if len(i.initializeSteps) == 0 { return ps, nil } @@ -51,7 +63,7 @@ func (i *Initialize) Initialize(ctx context.Context, ps *plugins.Plugin) (*plugi ip, err = init(ctx, ps) if err != nil { i.log.Error("Could not initialize plugin", "pluginId", ps.ID, "error", err) - return nil, err + return nil, tracing.Error(span, err) } } diff --git a/pkg/plugins/manager/pipeline/termination/termination.go b/pkg/plugins/manager/pipeline/termination/termination.go index 66e76d8a548..fdb28396bbf 100644 --- a/pkg/plugins/manager/pipeline/termination/termination.go +++ b/pkg/plugins/manager/pipeline/termination/termination.go @@ -3,9 +3,14 @@ package termination import ( "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/semconv" ) // Terminator is responsible for the Termination stage of the plugin loader pipeline. @@ -20,6 +25,7 @@ type Terminate struct { cfg *config.PluginManagementCfg terminateSteps []TerminateFunc log log.Logger + tracer trace.Tracer } type Opts struct { @@ -36,14 +42,20 @@ func New(cfg *config.PluginManagementCfg, opts Opts) (*Terminate, error) { cfg: cfg, terminateSteps: opts.TerminateFuncs, log: log.New("plugins.termination"), + tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination"), }, nil } // Terminate will execute the Terminate steps of the Termination stage. func (t *Terminate) Terminate(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { + ctx, span := t.tracer.Start(ctx, "termination.Terminate", trace.WithAttributes( + semconv.GrafanaPluginId(p.ID), + )) + defer span.End() + for _, terminate := range t.terminateSteps { if err := terminate(ctx, p); err != nil { - return nil, err + return nil, tracing.Error(span, err) } } return p, nil diff --git a/pkg/plugins/manager/pipeline/validation/validation.go b/pkg/plugins/manager/pipeline/validation/validation.go index 9ea7be9b3a7..36db1f25163 100644 --- a/pkg/plugins/manager/pipeline/validation/validation.go +++ b/pkg/plugins/manager/pipeline/validation/validation.go @@ -3,9 +3,14 @@ package validation import ( "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/semconv" ) // Validator is responsible for the Validation stage of the plugin loader pipeline. @@ -20,6 +25,7 @@ type Validate struct { cfg *config.PluginManagementCfg validateSteps []ValidateFunc log log.Logger + tracer trace.Tracer } type Opts struct { @@ -36,11 +42,17 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Validate { cfg: cfg, validateSteps: opts.ValidateFuncs, log: log.New("plugins.validation"), + tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation"), } } // Validate will execute the Validate steps of the Validation stage. func (v *Validate) Validate(ctx context.Context, ps *plugins.Plugin) error { + ctx, span := v.tracer.Start(ctx, "validation.Validate", trace.WithAttributes( + semconv.GrafanaPluginId(ps.ID), + )) + defer span.End() + if len(v.validateSteps) == 0 { return nil } @@ -49,7 +61,7 @@ func (v *Validate) Validate(ctx context.Context, ps *plugins.Plugin) error { err := validate(ctx, ps) if err != nil { v.log.Error("Plugin validation failed", "pluginId", ps.ID, "error", err) - return err + return tracing.Error(span, err) } } diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 7df610f375e..0a496e4667b 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -501,7 +501,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64, return nil, err } - // Group playlist items by playlist ID + // Group playlist items by playlist ID while preserving order type playlistData struct { id int64 uid string @@ -512,7 +512,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64, updatedAt int64 } - playlists := make(map[int64]*playlistData) + playlistIndex := make(map[int64]int) // maps playlist ID to index in playlists slice + playlists := []*playlistData{} var currentID int64 var orgID int64 var uid, name, interval string @@ -527,7 +528,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64, } // Get or create playlist entry - pl, exists := playlists[currentID] + idx, exists := playlistIndex[currentID] + var pl *playlistData if !exists { pl = &playlistData{ id: currentID, @@ -538,7 +540,10 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64, createdAt: createdAt, updatedAt: updatedAt, } - playlists[currentID] = pl + playlistIndex[currentID] = len(playlists) + playlists = append(playlists, pl) + } else { + pl = playlists[idx] } // Add item if it exists (LEFT JOIN can return NULL for playlists without items) @@ -554,7 +559,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64, return nil, err } - // Convert to K8s objects and send to stream + // Convert to K8s objects and send to stream (order is preserved) for _, pl := range playlists { playlist := &playlistv0.Playlist{ TypeMeta: metav1.TypeMeta{ diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index 9782ce6326d..2ad9c11582a 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -360,7 +360,17 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso }) } - list.TotalHits = int64(len(list.Results.Rows)) + // the UI expects us to populate "TotalHits" with the total search hits, not however many we are returning. + // this is a dumb workaround due to the fact that legacy doesn't expose a way of "counting search hits" + // it fixes a bug that only happens in mode 1-2 that prevents pagination from working in the dashboard list view + // we only have a handful of instances running in this mode and moving towards 0 instances fast, so this is fine + query.Limit = 0 + query.Page = 1 + res, err = c.dashboardStore.FindDashboards(ctx, query) + if err != nil { + return nil, err + } + list.TotalHits = int64(len(res)) return list, nil } diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go index cb7ead459f6..22907e24c8f 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -48,6 +48,18 @@ func TestDashboardSearchClient_Search(t *testing.T) { {ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2", Tags: []string{}}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: user, + Type: "dash-db", + Sort: sorter, + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Tags: []string{"term"}}, + {ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2", Tags: []string{}}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -124,6 +136,17 @@ func TestDashboardSearchClient_Search(t *testing.T) { {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50), Tags: []string{}}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: user, + Type: "dash-db", + Sort: sortOptionAsc, + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50), Tags: []string{}}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -189,6 +212,17 @@ func TestDashboardSearchClient_Search(t *testing.T) { {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2), Tags: []string{}}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: user, + Type: "dash-db", + Sort: sortOptionAsc, + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2), Tags: []string{}}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -293,6 +327,17 @@ func TestDashboardSearchClient_Search(t *testing.T) { {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + Title: "test", + SignedInUser: user, + Type: "dash-db", + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -319,6 +364,18 @@ func TestDashboardSearchClient_Search(t *testing.T) { {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + Title: "test", + TitleExactMatch: true, + SignedInUser: user, + Type: "dash-db", + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -350,6 +407,17 @@ func TestDashboardSearchClient_Search(t *testing.T) { {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + DashboardIds: []int64{1, 2}, + SignedInUser: user, + Type: "dash-db", + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -383,6 +451,19 @@ func TestDashboardSearchClient_Search(t *testing.T) { {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + DashboardUIDs: []string{"uid1", "uid2"}, + Tags: []string{"tag1", "tag2"}, + FolderUIDs: []string{"general", "folder1"}, + SignedInUser: user, + Type: "dash-db", + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -532,6 +613,17 @@ func TestDashboardSearchClient_Search(t *testing.T) { {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, }, nil).Once() + // Second call for total count with Page: 1, Limit: 0 + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: user, + Sort: sort.SortAlphaAsc, + Type: "dash-db", + Limit: 0, + Page: 1, + }).Return([]dashboards.DashboardSearchProjection{ + {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ Key: dashboardKey, @@ -552,7 +644,7 @@ func TestDashboardSearchClient_Search(t *testing.T) { t.Run("Should set correct sort field when sorting by views", func(t *testing.T) { mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{ {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", SortMeta: 100}, - }, nil).Once() + }, nil).Twice() // Will be called twice due to the total count call req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ @@ -584,7 +676,7 @@ func TestDashboardSearchClient_Search(t *testing.T) { mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{ {UID: "dashboard1", FolderUID: "folder1", ID: 1}, {UID: "dashboard2", FolderUID: "folder2", ID: 2}, - }, nil).Once() + }, nil).Twice() // Will be called twice due to the total count call req := &resourcepb.ResourceSearchRequest{ Options: &resourcepb.ListOptions{ diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 00821db3655..b0e726135a4 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/prometheus/client_golang/prometheus" ) @@ -53,7 +52,6 @@ type RepositoryController struct { repoLister listers.RepositoryLister repoSynced cache.InformerSynced logger logging.Logger - dualwrite dualwrite.Service jobs interface { jobs.Queue @@ -86,7 +84,6 @@ func NewRepositoryController( jobs.Queue jobs.Store }, - dualwrite dualwrite.Service, healthChecker *HealthChecker, statusPatcher StatusPatcher, registry prometheus.Registerer, @@ -114,11 +111,10 @@ func NewRepositoryController( metrics: &finalizerMetrics, maxWorkers: parallelOperations, }, - jobs: jobs, - logger: logging.DefaultLogger.With("logger", loggerName), - dualwrite: dualwrite, - registry: registry, - tracer: tracer, + jobs: jobs, + logger: logging.DefaultLogger.With("logger", loggerName), + registry: registry, + tracer: tracer, } _, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -356,9 +352,6 @@ func (rc *RepositoryController) determineSyncStrategy(ctx context.Context, obj * case !healthStatus.Healthy: logger.Info("skip sync for unhealthy repository") return nil - case rc.dualwrite != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, rc.dualwrite): - logger.Info("skip sync as we are reading from legacy storage") - return nil case healthStatus.Healthy != obj.Status.Health.Healthy: logger.Info("repository became healthy, full resync") return &provisioning.SyncJobOptions{} 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 e748228da2b..74e2ca4a26e 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.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package export 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 index 99718fd4d88..a2582001c9a 100644 --- 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 @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package export diff --git a/pkg/registry/apis/provisioning/jobs/loki_history_test.go b/pkg/registry/apis/provisioning/jobs/loki_history_test.go index 26d7f01ab4f..1f41875662c 100644 --- a/pkg/registry/apis/provisioning/jobs/loki_history_test.go +++ b/pkg/registry/apis/provisioning/jobs/loki_history_test.go @@ -49,7 +49,6 @@ func TestLokiJobHistory_WriteJob(t *testing.T) { Path: "/exported", }, Migrate: &provisioning.MigrateJobOptions{ - History: true, Message: "Migration test", }, Delete: &provisioning.DeleteJobOptions{ @@ -101,7 +100,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) { } t.Run("jobToStream creates correct stream with all fields", func(t *testing.T) { - history := createTestLokiJobHistory(t) + history := createTestLokiJobHistory() // Clean job copy like WriteJob does jobCopy := job.DeepCopy() delete(jobCopy.Labels, LabelJobClaim) @@ -147,7 +146,6 @@ func TestLokiJobHistory_WriteJob(t *testing.T) { assert.Equal(t, "main", deserializedJob.Spec.Push.Branch) assert.Equal(t, "/exported", deserializedJob.Spec.Push.Path) require.NotNil(t, deserializedJob.Spec.Migrate) - assert.True(t, deserializedJob.Spec.Migrate.History) assert.Equal(t, "Migration test", deserializedJob.Spec.Migrate.Message) require.NotNil(t, deserializedJob.Spec.Delete) assert.Equal(t, "main", deserializedJob.Spec.Delete.Ref) @@ -190,7 +188,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) { }) t.Run("buildJobQuery creates correct LogQL", func(t *testing.T) { - history := createTestLokiJobHistory(t) + history := createTestLokiJobHistory() query := history.buildJobQuery("test-ns", "test-repo") @@ -199,7 +197,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) { }) t.Run("getJobTimestamp returns correct timestamp", func(t *testing.T) { - history := createTestLokiJobHistory(t) + history := createTestLokiJobHistory() // Test finished time priority jobWithFinished := &provisioning.Job{ @@ -584,7 +582,7 @@ func TestLokiJobHistory_GetJob(t *testing.T) { } // createTestLokiJobHistory creates a LokiJobHistory for testing -func createTestLokiJobHistory(t *testing.T) *LokiJobHistory { +func createTestLokiJobHistory() *LokiJobHistory { // Create test URLs readURL, _ := url.Parse("http://localhost:3100") writeURL, _ := url.Parse("http://localhost:3100") diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy.go deleted file mode 100644 index 5b241c84775..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy.go +++ /dev/null @@ -1,95 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/grafana/grafana-app-sdk/logging" - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" -) - -type LegacyMigrator struct { - legacyMigrator LegacyResourcesMigrator - storageSwapper StorageSwapper - syncWorker jobs.Worker - wrapWithStageFn WrapWithStageFn -} - -func NewLegacyMigrator( - legacyMigrator LegacyResourcesMigrator, - storageSwapper StorageSwapper, - syncWorker jobs.Worker, - wrapWithStageFn WrapWithStageFn, -) *LegacyMigrator { - return &LegacyMigrator{ - legacyMigrator: legacyMigrator, - storageSwapper: storageSwapper, - syncWorker: syncWorker, - wrapWithStageFn: wrapWithStageFn, - } -} - -func (m *LegacyMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error { - namespace := rw.Config().Namespace - var stageMode repository.StageMode - if options.History { - // When History is true, we want to commit and push each file (previous PushOnWrites: true) - stageMode = repository.StageModeCommitAndPushOnEach - } else { - // When History is false, we want to commit only once (previous CommitOnlyOnce: true) - stageMode = repository.StageModeCommitOnlyOnce - } - - stageOptions := repository.StageOptions{ - Mode: stageMode, - CommitOnlyOnceMessage: options.Message, - // TODO: make this configurable - Timeout: 10 * time.Minute, - } - - // Fail if migrating at least one - progress.StrictMaxErrors(1) - progress.SetMessage(ctx, "migrating legacy resources") - - 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") - } - - return m.legacyMigrator.Migrate(ctx, rw, namespace, options, progress) - }); err != nil { - return fmt.Errorf("migrate from SQL: %w", err) - } - - progress.SetMessage(ctx, "resetting unified storage") - if err := m.storageSwapper.WipeUnifiedAndSetMigratedFlag(ctx, namespace); err != nil { - return fmt.Errorf("unable to reset unified storage %w", err) - } - - // Reset the results after the export as pull will operate on the same resources - progress.ResetResults() - - // Delegate the import to a sync (from the already checked out go-git repository!) - progress.SetMessage(ctx, "pulling resources") - if err := m.syncWorker.Process(ctx, rw, provisioning.Job{ - Spec: provisioning.JobSpec{ - Pull: &provisioning.SyncJobOptions{ - Incremental: false, - }, - }, - }, progress); err != nil { // this will have an error when too many errors exist - progress.SetMessage(ctx, "error importing resources, reverting") - if e2 := m.storageSwapper.StopReadingUnifiedStorage(ctx); e2 != nil { - logger := logging.FromContext(ctx) - logger.Warn("error trying to revert dual write settings after an error", "err", err) - } - return err - } - - return nil -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go deleted file mode 100644 index 48add96ee16..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go +++ /dev/null @@ -1,265 +0,0 @@ -package migrate - -import ( - "context" - "fmt" - - "k8s.io/apimachinery/pkg/runtime/schema" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature" - unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations" - "github.com/grafana/grafana/pkg/storage/unified/parquet" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -var _ resource.BulkResourceWriter = (*legacyResourceResourceMigrator)(nil) - -//go:generate mockery --name LegacyResourcesMigrator --structname MockLegacyResourcesMigrator --inpackage --filename mock_legacy_resources_migrator.go --with-expecter -type LegacyResourcesMigrator interface { - Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error -} - -type legacyResourcesMigrator struct { - repositoryResources resources.RepositoryResourcesFactory - parsers resources.ParserFactory - dashboardAccess legacy.MigrationDashboardAccessor - signerFactory signature.SignerFactory - clients resources.ClientFactory - exportFn export.ExportFn -} - -func NewLegacyResourcesMigrator( - repositoryResources resources.RepositoryResourcesFactory, - parsers resources.ParserFactory, - dashboardAccess legacy.MigrationDashboardAccessor, - signerFactory signature.SignerFactory, - clients resources.ClientFactory, - exportFn export.ExportFn, -) LegacyResourcesMigrator { - return &legacyResourcesMigrator{ - repositoryResources: repositoryResources, - parsers: parsers, - dashboardAccess: dashboardAccess, - signerFactory: signerFactory, - clients: clients, - exportFn: exportFn, - } -} - -func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error { - parser, err := m.parsers.GetParser(ctx, rw) - if err != nil { - return fmt.Errorf("get parser: %w", err) - } - - repositoryResources, err := m.repositoryResources.Client(ctx, rw) - if err != nil { - return fmt.Errorf("get repository resources: %w", err) - } - - // FIXME: signature is only relevant for repositories which support signature - // Not all repositories support history - signer, err := m.signerFactory.New(ctx, signature.SignOptions{ - Namespace: namespace, - History: opts.History, - }) - if err != nil { - return fmt.Errorf("get signer: %w", err) - } - - progress.SetMessage(ctx, "migrate folders from SQL") - clients, err := m.clients.Clients(ctx, namespace) - if err != nil { - return err - } - - // nothing special for the export for now - exportOpts := provisioning.ExportJobOptions{} - if err = m.exportFn(ctx, rw.Config().Name, exportOpts, clients, repositoryResources, progress); err != nil { - return fmt.Errorf("migrate folders from SQL: %w", err) - } - - progress.SetMessage(ctx, "migrate resources from SQL") - for _, kind := range resources.SupportedProvisioningResources { - if kind == resources.FolderResource { - continue // folders have special handling - } - - reader := newLegacyResourceMigrator( - rw, - m.dashboardAccess, - parser, - repositoryResources, - progress, - opts, - namespace, - kind.GroupResource(), - signer, - ) - - if err := reader.Migrate(ctx); err != nil { - return fmt.Errorf("migrate resource %s: %w", kind, err) - } - } - - return nil -} - -type legacyResourceResourceMigrator struct { - repo repository.ReaderWriter - dashboardAccess legacy.MigrationDashboardAccessor - parser resources.Parser - progress jobs.JobProgressRecorder - namespace string - kind schema.GroupResource - options provisioning.MigrateJobOptions - resources resources.RepositoryResources - signer signature.Signer - history map[string]string // UID >> file path -} - -func newLegacyResourceMigrator( - repo repository.ReaderWriter, - dashboardAccess legacy.MigrationDashboardAccessor, - parser resources.Parser, - resources resources.RepositoryResources, - progress jobs.JobProgressRecorder, - options provisioning.MigrateJobOptions, - namespace string, - kind schema.GroupResource, - signer signature.Signer, -) *legacyResourceResourceMigrator { - var history map[string]string - if options.History { - history = make(map[string]string) - } - return &legacyResourceResourceMigrator{ - repo: repo, - dashboardAccess: dashboardAccess, - parser: parser, - progress: progress, - options: options, - namespace: namespace, - kind: kind, - resources: resources, - signer: signer, - history: history, - } -} - -// Close implements resource.BulkResourceWriter. -func (r *legacyResourceResourceMigrator) Close() error { - return nil -} - -// CloseWithResults implements resource.BulkResourceWriter. -func (r *legacyResourceResourceMigrator) CloseWithResults() (*resourcepb.BulkResponse, error) { - return &resourcepb.BulkResponse{}, nil -} - -// Write implements resource.BulkResourceWriter. -func (r *legacyResourceResourceMigrator) Write(ctx context.Context, key *resourcepb.ResourceKey, value []byte) error { - // Reuse the same parse+cleanup logic - parsed, err := r.parser.Parse(ctx, &repository.FileInfo{ - Path: "", // empty path to ignore file system - Data: value, - }) - if err != nil { - return fmt.Errorf("unmarshal unstructured: %w", err) - } - - // clear anything so it will get written - parsed.Meta.SetManagerProperties(utils.ManagerProperties{}) - parsed.Meta.SetSourceProperties(utils.SourceProperties{}) - - // Add author signature to the context - ctx, err = r.signer.Sign(ctx, parsed.Meta) - if err != nil { - return fmt.Errorf("add author signature: %w", err) - } - - // TODO: this seems to be same logic as the export job - // TODO: we should use a kind safe manager here - fileName, err := r.resources.WriteResourceFileFromObject(ctx, parsed.Obj, resources.WriteOptions{ - Path: "", - Ref: "", - }) - - // When replaying history, the path to the file may change over time - // This happens when the title or folder change - if r.history != nil && err == nil { - name := parsed.Meta.GetName() - previous := r.history[name] - if previous != "" && previous != fileName { - err = r.repo.Delete(ctx, previous, "", fmt.Sprintf("moved to: %s", fileName)) - } - r.history[name] = fileName - } - - result := jobs.JobResourceResult{ - Name: parsed.Meta.GetName(), - Group: r.kind.Group, - Kind: parsed.GVK.Kind, - Action: repository.FileActionCreated, - Path: fileName, - } - - if err != nil { - result.Error = fmt.Errorf("writing resource %s/%s %s to file %s: %w", r.kind.Group, r.kind.Resource, parsed.Meta.GetName(), fileName, err) - } - - r.progress.Record(ctx, result) - if err := r.progress.TooManyErrors(); err != nil { - return err - } - - return nil -} - -func (r *legacyResourceResourceMigrator) Migrate(ctx context.Context) error { - r.progress.SetMessage(ctx, fmt.Sprintf("migrate %s resource", r.kind.Resource)) - - // Create a parquet migrator with this instance as the BulkResourceWriter - parquetClient := parquet.NewBulkResourceWriterClient(r) - migrator := unifiedmigrations.ProvideUnifiedMigratorParquet( - r.dashboardAccess, - parquetClient, - ) - - opts := legacy.MigrateOptions{ - Namespace: r.namespace, - WithHistory: r.options.History, - Resources: []schema.GroupResource{r.kind}, - OnlyCount: true, // first get the count - } - stats, err := migrator.Migrate(ctx, opts) - if err != nil { - return fmt.Errorf("unable to count legacy items %w", err) - } - - // FIXME: explain why we calculate it in this way - if len(stats.Summary) > 0 { - count := stats.Summary[0].Count // - history := stats.Summary[0].History - if history > count { - count = history // the number of items we will process - } - r.progress.SetTotal(ctx, int(count)) - } - - opts.OnlyCount = false // this time actually write - _, err = migrator.Migrate(ctx, opts) - if err != nil { - return fmt.Errorf("migrate legacy %s: %w", r.kind.Resource, err) - } - - return nil -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go deleted file mode 100644 index 7f4318639c2..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go +++ /dev/null @@ -1,934 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -func TestLegacyResourcesMigrator_Migrate(t *testing.T) { - t.Run("should fail when parser factory fails", func(t *testing.T) { - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(nil, errors.New("parser factory error")) - - signerFactory := signature.NewMockSignerFactory(t) - mockClientFactory := resources.NewMockClientFactory(t) - mockExportFn := export.NewMockExportFn(t) - - migrator := NewLegacyResourcesMigrator( - nil, - mockParserFactory, - nil, - signerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - - err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t)) - require.Error(t, err) - require.EqualError(t, err, "get parser: parser factory error") - - mockParserFactory.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - }) - - t.Run("should fail when repository resources factory fails", func(t *testing.T) { - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(resources.NewMockParser(t), nil) - - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(nil, errors.New("repo resources factory error")) - signerFactory := signature.NewMockSignerFactory(t) - mockClientFactory := resources.NewMockClientFactory(t) - mockExportFn := export.NewMockExportFn(t) - - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - nil, - signerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - - err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t)) - require.Error(t, err) - require.EqualError(t, err, "get repository resources: repo resources factory error") - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - }) - - t.Run("should fail when resource migration fails", func(t *testing.T) { - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(resources.NewMockParser(t), nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(mockRepoResources, nil) - - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, errors.New("legacy migrator error")) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - - signer := signature.NewMockSigner(t) - signerFactory := signature.NewMockSignerFactory(t) - signerFactory.On("New", mock.Anything, mock.Anything). - Return(signer, nil) - - mockClients := resources.NewMockResourceClients(t) - mockClientFactory := resources.NewMockClientFactory(t) - mockClientFactory.On("Clients", mock.Anything, "test-namespace"). - Return(mockClients, nil) - mockExportFn := export.NewMockExportFn(t) - - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - mockDashboardAccess, - signerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - Name: "test-repo", - }, - }) - mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything). - Return(nil) - - err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress) - require.Error(t, err) - require.Contains(t, err.Error(), "migrate resource") - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - mockClients.AssertExpectations(t) - repo.AssertExpectations(t) - }) - t.Run("should fail when client creation fails", func(t *testing.T) { - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(resources.NewMockParser(t), nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(mockRepoResources, nil) - - mockSigner := signature.NewMockSigner(t) - mockSignerFactory := signature.NewMockSignerFactory(t) - mockSignerFactory.On("New", mock.Anything, mock.Anything). - Return(mockSigner, nil) - - mockClientFactory := resources.NewMockClientFactory(t) - mockClientFactory.On("Clients", mock.Anything, "test-namespace"). - Return(nil, errors.New("client creation error")) - - mockExportFn := export.NewMockExportFn(t) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return() - - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - nil, - mockSignerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - - repo := repository.NewMockRepository(t) - err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress) - require.Error(t, err) - require.EqualError(t, err, "client creation error") - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockSignerFactory.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - progress.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - repo.AssertExpectations(t) - }) - - t.Run("should fail when signer factory fails", func(t *testing.T) { - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(resources.NewMockParser(t), nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(mockRepoResources, nil) - - mockSignerFactory := signature.NewMockSignerFactory(t) - mockSignerFactory.On("New", mock.Anything, signature.SignOptions{ - Namespace: "test-namespace", - History: true, - }).Return(nil, fmt.Errorf("signer factory error")) - - mockClientFactory := resources.NewMockClientFactory(t) - mockExportFn := export.NewMockExportFn(t) - - progress := jobs.NewMockJobProgressRecorder(t) - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - nil, - mockSignerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - - err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{ - History: true, - }, progress) - require.Error(t, err) - require.EqualError(t, err, "get signer: signer factory error") - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockSignerFactory.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - progress.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - }) - t.Run("should fail when folder export fails", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(mockParser, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(mockRepoResources, nil) - - mockSigner := signature.NewMockSigner(t) - mockSignerFactory := signature.NewMockSignerFactory(t) - mockSignerFactory.On("New", mock.Anything, signature.SignOptions{ - Namespace: "test-namespace", - History: false, - }).Return(mockSigner, nil) - - mockClients := resources.NewMockResourceClients(t) - mockClientFactory := resources.NewMockClientFactory(t) - mockClientFactory.On("Clients", mock.Anything, "test-namespace"). - Return(mockClients, nil) - - mockExportFn := export.NewMockExportFn(t) - mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything). - Return(fmt.Errorf("export error")) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return() - - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - nil, - mockSignerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - Name: "test-repo", - }, - }) - - err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress) - require.Error(t, err) - require.Contains(t, err.Error(), "migrate folders from SQL: export error") - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockSignerFactory.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should successfully migrate all resources", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - mockParserFactory := resources.NewMockParserFactory(t) - mockParserFactory.On("GetParser", mock.Anything, mock.Anything). - Return(mockParser, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). - Return(mockRepoResources, nil) - - mockSigner := signature.NewMockSigner(t) - mockSignerFactory := signature.NewMockSignerFactory(t) - mockSignerFactory.On("New", mock.Anything, signature.SignOptions{ - Namespace: "test-namespace", - History: true, - }).Return(mockSigner, nil) - - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - // Mock CountResources for the count phase - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() - // Mock MigrateDashboards for the actual migration phase (dashboards resource) - mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return !opts.OnlyCount && opts.Namespace == "test-namespace" - }), mock.Anything).Return(&legacy.BlobStoreInfo{ - Count: 10, - Size: 5, - }, nil).Once() - - mockClients := resources.NewMockResourceClients(t) - mockClientFactory := resources.NewMockClientFactory(t) - mockClientFactory.On("Clients", mock.Anything, "test-namespace"). - Return(mockClients, nil) - mockExportFn := export.NewMockExportFn(t) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return() - progress.On("SetMessage", mock.Anything, "migrate resources from SQL").Return() - progress.On("SetMessage", mock.Anything, "migrate dashboards resource").Return() - - migrator := NewLegacyResourcesMigrator( - mockRepoResourcesFactory, - mockParserFactory, - mockDashboardAccess, - mockSignerFactory, - mockClientFactory, - mockExportFn.Execute, - ) - - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - Name: "test-repo", - }, - }) - mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything). - Return(nil) - - err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{ - History: true, - }, progress) - require.NoError(t, err) - - mockParserFactory.AssertExpectations(t) - mockRepoResourcesFactory.AssertExpectations(t) - mockDashboardAccess.AssertExpectations(t) - mockClientFactory.AssertExpectations(t) - mockExportFn.AssertExpectations(t) - progress.AssertExpectations(t) - mockClients.AssertExpectations(t) - }) -} - -func TestLegacyResourceResourceMigrator_Write(t *testing.T) { - t.Run("should fail when parser fails", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(nil, errors.New("parser error")) - - progress := jobs.NewMockJobProgressRecorder(t) - - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - err := migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.Error(t, err) - require.Contains(t, err.Error(), "unmarshal unstructured") - - mockParser.AssertExpectations(t) - }) - - t.Run("records error when create resource file fails", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything). - Return("", errors.New("create file error")) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Name == "test" && - result.Error != nil && - result.Error.Error() == "writing resource test.grafana.app/tests test to file : create file error" - })).Return() - progress.On("TooManyErrors").Return(nil) - - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - mockRepoResources, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.NoError(t, err) // Error is recorded but not returned - - mockParser.AssertExpectations(t) - mockRepoResources.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should fail when signer fails", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockSigner := signature.NewMockSigner(t) - mockSigner.On("Sign", mock.Anything, meta). - Return(nil, errors.New("signing error")) - - progress := jobs.NewMockJobProgressRecorder(t) - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - mockSigner, - ) - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.Error(t, err) - require.EqualError(t, err, "add author signature: signing error") - - mockParser.AssertExpectations(t) - mockSigner.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should successfully add author signature", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockSigner := signature.NewMockSigner(t) - signedCtx := repository.WithAuthorSignature(context.Background(), repository.CommitSignature{ - Name: "test-user", - Email: "test@example.com", - }) - mockSigner.On("Sign", mock.Anything, meta). - Return(signedCtx, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResources.On("WriteResourceFileFromObject", signedCtx, mock.Anything, mock.Anything). - Return("test/path", nil) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Name == "test" && - result.Error == nil && - result.Path == "test/path" - })).Return() - progress.On("TooManyErrors").Return(nil) - - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - mockRepoResources, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - mockSigner, - ) - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.NoError(t, err) - - mockParser.AssertExpectations(t) - mockSigner.AssertExpectations(t) - mockRepoResources.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should maintain history", func(t *testing.T) { - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(nil) - - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, _ := utils.MetaAccessor(obj) - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockRepo := repository.NewMockRepository(t) - - mockRepoResources := resources.NewMockRepositoryResources(t) - writeResourceFileFromObject := mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything) - - migrator := newLegacyResourceMigrator( - mockRepo, - nil, - mockParser, - mockRepoResources, - progress, - provisioning.MigrateJobOptions{ - History: true, - }, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - writeResourceFileFromObject.Return("aaaa.json", nil) - err := migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("")) - require.NoError(t, err) - require.Equal(t, "aaaa.json", migrator.history["test"], "kept track of the old files") - - // Change the result file name - writeResourceFileFromObject.Return("bbbb.json", nil) - mockRepo.On("Delete", mock.Anything, "aaaa.json", "", "moved to: bbbb.json"). - Return(nil).Once() - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("")) - require.NoError(t, err) - require.Equal(t, "bbbb.json", migrator.history["test"], "kept track of the old files") - - mockParser.AssertExpectations(t) - mockRepoResources.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should successfully write resource", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - meta.SetManagerProperties(utils.ManagerProperties{ - Kind: utils.ManagerKindRepo, - Identity: "test", - AllowsEdits: true, - Suspended: false, - }) - meta.SetSourceProperties(utils.SourceProperties{ - Path: "test", - Checksum: "test", - TimestampMillis: 1234567890, - }) - - mockParser.On("Parse", mock.Anything, mock.MatchedBy(func(info *repository.FileInfo) bool { - return info != nil && info.Path == "" && string(info.Data) == "test" - })). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - if obj == nil { - return false - } - if obj.GetName() != "test" { - return false - } - - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - managerProps, _ := meta.GetManagerProperties() - sourceProps, _ := meta.GetSourceProperties() - - return assert.Zero(t, sourceProps) && assert.Zero(t, managerProps) - }), resources.WriteOptions{ - Path: "", - Ref: "", - }). - Return("test/path", nil) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Name == "test" && - result.Error == nil && - result.Kind == "" && // empty kind - result.Group == "test.grafana.app" && - result.Path == "test/path" - })).Return() - progress.On("TooManyErrors").Return(nil) - - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - mockRepoResources, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.NoError(t, err) - - mockParser.AssertExpectations(t) - mockRepoResources.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should fail when too many errors", func(t *testing.T) { - mockParser := resources.NewMockParser(t) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - } - meta, err := utils.MetaAccessor(obj) - require.NoError(t, err) - - mockParser.On("Parse", mock.Anything, mock.Anything). - Return(&resources.ParsedResource{ - Meta: meta, - Obj: obj, - }, nil) - - mockRepoResources := resources.NewMockRepositoryResources(t) - mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, resources.WriteOptions{}). - Return("test/path", nil) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(errors.New("too many errors")) - - migrator := newLegacyResourceMigrator( - nil, - nil, - mockParser, - mockRepoResources, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test")) - require.EqualError(t, err, "too many errors") - - mockParser.AssertExpectations(t) - mockRepoResources.AssertExpectations(t) - progress.AssertExpectations(t) - }) -} - -func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { - t.Run("should fail when legacy migrate count fails", func(t *testing.T) { - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, errors.New("count error")) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - - migrator := newLegacyResourceMigrator( - nil, - mockDashboardAccess, - nil, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, - signature.NewGrafanaSigner(), - ) - - err := migrator.Migrate(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), "unable to count legacy items") - - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should fail when legacy migrate write fails", func(t *testing.T) { - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase - // For test-resources GroupResource, we don't know which method it will call, but since it's not dashboards/folders/librarypanels, - // the Migrate will fail trying to map the resource type. Let's make it dashboards for this test. - mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return !opts.OnlyCount && opts.Namespace == "test-namespace" - }), mock.Anything).Return(nil, errors.New("write error")).Once() // Write phase - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - - migrator := newLegacyResourceMigrator( - nil, - mockDashboardAccess, - nil, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, - signature.NewGrafanaSigner(), - ) - - err := migrator.Migrate(context.Background()) - require.Error(t, err) - require.Contains(t, err.Error(), "migrate legacy dashboards: write error") - - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - }) - - t.Run("should successfully migrate resource", func(t *testing.T) { - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase - mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return !opts.OnlyCount && opts.Namespace == "test-namespace" - }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - - migrator := newLegacyResourceMigrator( - nil, - mockDashboardAccess, - nil, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, - signature.NewGrafanaSigner(), - ) - - err := migrator.Migrate(context.Background()) - require.NoError(t, err) - - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - }) - t.Run("should set total to history if history is greater than count", func(t *testing.T) { - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{ - Summary: []*resourcepb.BulkResponse_Summary{ - { - Group: "dashboard.grafana.app", - Resource: "dashboards", - Count: 1, - History: 100, - }, - }, - }, nil).Once() // Count phase - mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return !opts.OnlyCount && opts.Namespace == "test-namespace" - }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - progress.On("SetTotal", mock.Anything, 100).Return() - - migrator := newLegacyResourceMigrator( - nil, - mockDashboardAccess, - nil, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, - signature.NewGrafanaSigner(), - ) - - err := migrator.Migrate(context.Background()) - require.NoError(t, err) - - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - }) - t.Run("should set total to count if history is less than count", func(t *testing.T) { - mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) - mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{ - Summary: []*resourcepb.BulkResponse_Summary{ - { - Group: "dashboard.grafana.app", - Resource: "dashboards", - Count: 200, - History: 1, - }, - }, - }, nil).Once() // Count phase - mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { - return !opts.OnlyCount && opts.Namespace == "test-namespace" - }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("SetMessage", mock.Anything, mock.Anything).Return() - progress.On("SetTotal", mock.Anything, 200).Return() - signer := signature.NewMockSigner(t) - - migrator := newLegacyResourceMigrator( - nil, - mockDashboardAccess, - nil, - nil, - progress, - provisioning.MigrateJobOptions{}, - "test-namespace", - schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, - signer, - ) - - err := migrator.Migrate(context.Background()) - require.NoError(t, err) - - mockDashboardAccess.AssertExpectations(t) - progress.AssertExpectations(t) - }) -} - -func TestLegacyResourceResourceMigrator_Close(t *testing.T) { - t.Run("should return nil error", func(t *testing.T) { - migrator := &legacyResourceResourceMigrator{} - err := migrator.Close() - require.NoError(t, err) - }) -} - -func TestLegacyResourceResourceMigrator_CloseWithResults(t *testing.T) { - t.Run("should return empty bulk response and nil error", func(t *testing.T) { - migrator := &legacyResourceResourceMigrator{} - response, err := migrator.CloseWithResults() - - require.NoError(t, err) - require.NotNil(t, response) - require.IsType(t, &resourcepb.BulkResponse{}, response) - require.Empty(t, response.Summary) - }) -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go deleted file mode 100644 index 6b16fb3da7a..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_test.go +++ /dev/null @@ -1,440 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "testing" - "time" - - mock "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" -) - -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, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - // pass a reader to function call - repo := repository.NewMockReader(t) - return fn(repo, true) - } - - legacyFoldersMigrator := NewLegacyMigrator( - NewMockLegacyResourcesMigrator(t), - NewMockStorageSwapper(t), - jobs.NewMockWorker(t), - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - err := legacyFoldersMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "migration job submitted targeting repository that is not a ReaderWriter") - }) -} -func TestWrapWithCloneFn_Error(t *testing.T) { - t.Run("should return error when wrapFn fails", func(t *testing.T) { - // Setup - ctx := context.Background() - expectedErr := errors.New("clone failed") - - // Create the wrapper function that returns an error - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return expectedErr - } - - legacyMigrator := NewLegacyMigrator( - NewMockLegacyResourcesMigrator(t), - NewMockStorageSwapper(t), - jobs.NewMockWorker(t), - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "migrate from SQL: clone failed") - }) -} - -func TestLegacyMigrator_MigrateFails(t *testing.T) { - t.Run("should return error when legacyMigrator.Migrate fails", func(t *testing.T) { - // Setup - ctx := context.Background() - expectedErr := errors.New("migration failed") - - mockLegacyMigrator := NewMockLegacyResourcesMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything). - Return(expectedErr) - - mockStorageSwapper := NewMockStorageSwapper(t) - mockWorker := jobs.NewMockWorker(t) - - // Create a wrapper function that calls the provided function - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return fn(rw, true) - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "migrate from SQL: migration failed") - - // Storage swapper should not be called when migration fails - mockStorageSwapper.AssertNotCalled(t, "WipeUnifiedAndSetMigratedFlag") - }) -} - -func TestLegacyMigrator_ResetUnifiedStorageFails(t *testing.T) { - t.Run("should return error when storage reset fails", func(t *testing.T) { - // Setup - ctx := context.Background() - expectedErr := errors.New("reset failed") - - mockLegacyMigrator := NewMockLegacyResourcesMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything). - Return(nil) - - mockStorageSwapper := NewMockStorageSwapper(t) - mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace"). - Return(expectedErr) - - mockWorker := jobs.NewMockWorker(t) - - // Create a wrapper function that calls the provided function - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return fn(rw, true) - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - progress.On("SetMessage", mock.Anything, "resetting unified storage").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "unable to reset unified storage") - - // Sync worker should not be called when reset fails - mockWorker.AssertNotCalled(t, "Process") - }) -} - -func TestLegacyMigrator_SyncFails(t *testing.T) { - t.Run("should revert storage settings when sync fails", func(t *testing.T) { - // Setup - ctx := context.Background() - expectedErr := errors.New("sync failed") - - mockLegacyMigrator := NewMockLegacyResourcesMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything). - Return(nil) - - mockStorageSwapper := NewMockStorageSwapper(t) - mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace"). - Return(nil) - mockStorageSwapper.On("StopReadingUnifiedStorage", mock.Anything). - Return(nil) - - mockWorker := jobs.NewMockWorker(t) - mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool { - return job.Spec.Pull != nil && !job.Spec.Pull.Incremental - }), mock.Anything).Return(expectedErr) - - // Create a wrapper function that calls the provided function - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return fn(rw, true) - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - progress.On("SetMessage", mock.Anything, "resetting unified storage").Return() - progress.On("ResetResults").Return() - progress.On("SetMessage", mock.Anything, "pulling resources").Return() - progress.On("SetMessage", mock.Anything, "error importing resources, reverting").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "sync failed") - - // Verify storage settings were reverted - mockStorageSwapper.AssertCalled(t, "StopReadingUnifiedStorage", mock.Anything) - }) - - t.Run("should handle revert failure after sync failure", func(t *testing.T) { - // Setup - ctx := context.Background() - syncErr := errors.New("sync failed") - revertErr := errors.New("revert failed") - - mockLegacyMigrator := NewMockLegacyResourcesMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything). - Return(nil) - - mockStorageSwapper := NewMockStorageSwapper(t) - mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace"). - Return(nil) - mockStorageSwapper.On("StopReadingUnifiedStorage", mock.Anything). - Return(revertErr) - - mockWorker := jobs.NewMockWorker(t) - mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool { - return job.Spec.Pull != nil && !job.Spec.Pull.Incremental - }), mock.Anything).Return(syncErr) - - // Create a wrapper function that calls the provided function - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return fn(rw, true) - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - progress.On("SetMessage", mock.Anything, "resetting unified storage").Return() - progress.On("ResetResults").Return() - progress.On("SetMessage", mock.Anything, "pulling resources").Return() - progress.On("SetMessage", mock.Anything, "error importing resources, reverting").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.Error(t, err) - require.Contains(t, err.Error(), "sync failed") - - // Verify both errors occurred - mockStorageSwapper.AssertCalled(t, "StopReadingUnifiedStorage", mock.Anything) - }) -} - -func TestLegacyMigrator_Success(t *testing.T) { - t.Run("should complete migration successfully", func(t *testing.T) { - // Setup - ctx := context.Background() - - mockLegacyMigrator := NewMockLegacyResourcesMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything). - Return(nil) - - mockStorageSwapper := NewMockStorageSwapper(t) - mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace"). - Return(nil) - - mockWorker := jobs.NewMockWorker(t) - mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool { - return job.Spec.Pull != nil && !job.Spec.Pull.Incremental - }), mock.Anything).Return(nil) - - // Create a wrapper function that calls the provided function - wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return fn(rw, true) - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - progress.On("SetMessage", mock.Anything, "resetting unified storage").Return() - progress.On("ResetResults").Return() - progress.On("SetMessage", mock.Anything, "pulling resources").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress) - - // Assert - require.NoError(t, err) - - // Verify all expected operations were called in order - mockLegacyMigrator.AssertCalled(t, "Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything) - mockStorageSwapper.AssertCalled(t, "WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace") - mockWorker.AssertCalled(t, "Process", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) -} - -func TestLegacyMigrator_BeforeFnExecution(t *testing.T) { - t.Run("should execute beforeFn functions", func(t *testing.T) { - // Setup - mockLegacyMigrator := NewMockLegacyResourcesMigrator(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, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return errors.New("abort test here") - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - // No progress messages expected in current staging implementation - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - - // Execute - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(context.Background(), repo, provisioning.MigrateJobOptions{}, progress) - require.EqualError(t, err, "migrate from SQL: abort test here") - }) -} - -func TestLegacyMigrator_ProgressScanner(t *testing.T) { - t.Run("should update progress with scanner", func(t *testing.T) { - mockLegacyMigrator := NewMockLegacyResourcesMigrator(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, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { - return errors.New("abort test here") - } - - legacyMigrator := NewLegacyMigrator( - mockLegacyMigrator, - mockStorageSwapper, - mockWorker, - wrapFn, - ) - - progress := jobs.NewMockJobProgressRecorder(t) - // No progress messages expected in current staging implementation - progress.On("StrictMaxErrors", 1).Return() - progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return() - - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test-namespace", - }, - }) - - err := legacyMigrator.Migrate(context.Background(), repo, provisioning.MigrateJobOptions{}, progress) - require.EqualError(t, err, "migrate from SQL: abort test here") - - require.Eventually(t, func() bool { - // 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_process_client.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_process_client.go deleted file mode 100644 index e16d5695c0d..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_process_client.go +++ /dev/null @@ -1,430 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package migrate - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" - metadata "google.golang.org/grpc/metadata" - - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -// BulkStore_BulkProcessClient is an autogenerated mock type for the BulkStore_BulkProcessClient type -type BulkStore_BulkProcessClient struct { - mock.Mock -} - -type BulkStore_BulkProcessClient_Expecter struct { - mock *mock.Mock -} - -func (_m *BulkStore_BulkProcessClient) EXPECT() *BulkStore_BulkProcessClient_Expecter { - return &BulkStore_BulkProcessClient_Expecter{mock: &_m.Mock} -} - -// CloseAndRecv provides a mock function with no fields -func (_m *BulkStore_BulkProcessClient) CloseAndRecv() (*resourcepb.BulkResponse, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for CloseAndRecv") - } - - var r0 *resourcepb.BulkResponse - var r1 error - if rf, ok := ret.Get(0).(func() (*resourcepb.BulkResponse, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() *resourcepb.BulkResponse); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*resourcepb.BulkResponse) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// BulkStore_BulkProcessClient_CloseAndRecv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseAndRecv' -type BulkStore_BulkProcessClient_CloseAndRecv_Call struct { - *mock.Call -} - -// CloseAndRecv is a helper method to define mock.On call -func (_e *BulkStore_BulkProcessClient_Expecter) CloseAndRecv() *BulkStore_BulkProcessClient_CloseAndRecv_Call { - return &BulkStore_BulkProcessClient_CloseAndRecv_Call{Call: _e.mock.On("CloseAndRecv")} -} - -func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) Run(run func()) *BulkStore_BulkProcessClient_CloseAndRecv_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) Return(_a0 *resourcepb.BulkResponse, _a1 error) *BulkStore_BulkProcessClient_CloseAndRecv_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) RunAndReturn(run func() (*resourcepb.BulkResponse, error)) *BulkStore_BulkProcessClient_CloseAndRecv_Call { - _c.Call.Return(run) - return _c -} - -// CloseSend provides a mock function with no fields -func (_m *BulkStore_BulkProcessClient) CloseSend() error { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for CloseSend") - } - - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { - r0 = rf() - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// BulkStore_BulkProcessClient_CloseSend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseSend' -type BulkStore_BulkProcessClient_CloseSend_Call struct { - *mock.Call -} - -// CloseSend is a helper method to define mock.On call -func (_e *BulkStore_BulkProcessClient_Expecter) CloseSend() *BulkStore_BulkProcessClient_CloseSend_Call { - return &BulkStore_BulkProcessClient_CloseSend_Call{Call: _e.mock.On("CloseSend")} -} - -func (_c *BulkStore_BulkProcessClient_CloseSend_Call) Run(run func()) *BulkStore_BulkProcessClient_CloseSend_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_CloseSend_Call) Return(_a0 error) *BulkStore_BulkProcessClient_CloseSend_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_CloseSend_Call) RunAndReturn(run func() error) *BulkStore_BulkProcessClient_CloseSend_Call { - _c.Call.Return(run) - return _c -} - -// Context provides a mock function with no fields -func (_m *BulkStore_BulkProcessClient) Context() context.Context { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for Context") - } - - var r0 context.Context - if rf, ok := ret.Get(0).(func() context.Context); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(context.Context) - } - } - - return r0 -} - -// BulkStore_BulkProcessClient_Context_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Context' -type BulkStore_BulkProcessClient_Context_Call struct { - *mock.Call -} - -// Context is a helper method to define mock.On call -func (_e *BulkStore_BulkProcessClient_Expecter) Context() *BulkStore_BulkProcessClient_Context_Call { - return &BulkStore_BulkProcessClient_Context_Call{Call: _e.mock.On("Context")} -} - -func (_c *BulkStore_BulkProcessClient_Context_Call) Run(run func()) *BulkStore_BulkProcessClient_Context_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Context_Call) Return(_a0 context.Context) *BulkStore_BulkProcessClient_Context_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Context_Call) RunAndReturn(run func() context.Context) *BulkStore_BulkProcessClient_Context_Call { - _c.Call.Return(run) - return _c -} - -// Header provides a mock function with no fields -func (_m *BulkStore_BulkProcessClient) Header() (metadata.MD, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for Header") - } - - var r0 metadata.MD - var r1 error - if rf, ok := ret.Get(0).(func() (metadata.MD, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() metadata.MD); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(metadata.MD) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// BulkStore_BulkProcessClient_Header_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Header' -type BulkStore_BulkProcessClient_Header_Call struct { - *mock.Call -} - -// Header is a helper method to define mock.On call -func (_e *BulkStore_BulkProcessClient_Expecter) Header() *BulkStore_BulkProcessClient_Header_Call { - return &BulkStore_BulkProcessClient_Header_Call{Call: _e.mock.On("Header")} -} - -func (_c *BulkStore_BulkProcessClient_Header_Call) Run(run func()) *BulkStore_BulkProcessClient_Header_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Header_Call) Return(_a0 metadata.MD, _a1 error) *BulkStore_BulkProcessClient_Header_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Header_Call) RunAndReturn(run func() (metadata.MD, error)) *BulkStore_BulkProcessClient_Header_Call { - _c.Call.Return(run) - return _c -} - -// RecvMsg provides a mock function with given fields: m -func (_m *BulkStore_BulkProcessClient) RecvMsg(m interface{}) error { - ret := _m.Called(m) - - if len(ret) == 0 { - panic("no return value specified for RecvMsg") - } - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(m) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// BulkStore_BulkProcessClient_RecvMsg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecvMsg' -type BulkStore_BulkProcessClient_RecvMsg_Call struct { - *mock.Call -} - -// RecvMsg is a helper method to define mock.On call -// - m interface{} -func (_e *BulkStore_BulkProcessClient_Expecter) RecvMsg(m interface{}) *BulkStore_BulkProcessClient_RecvMsg_Call { - return &BulkStore_BulkProcessClient_RecvMsg_Call{Call: _e.mock.On("RecvMsg", m)} -} - -func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) Run(run func(m interface{})) *BulkStore_BulkProcessClient_RecvMsg_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(interface{})) - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) Return(_a0 error) *BulkStore_BulkProcessClient_RecvMsg_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) RunAndReturn(run func(interface{}) error) *BulkStore_BulkProcessClient_RecvMsg_Call { - _c.Call.Return(run) - return _c -} - -// Send provides a mock function with given fields: _a0 -func (_m *BulkStore_BulkProcessClient) Send(_a0 *resourcepb.BulkRequest) error { - ret := _m.Called(_a0) - - if len(ret) == 0 { - panic("no return value specified for Send") - } - - var r0 error - if rf, ok := ret.Get(0).(func(*resourcepb.BulkRequest) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// BulkStore_BulkProcessClient_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send' -type BulkStore_BulkProcessClient_Send_Call struct { - *mock.Call -} - -// Send is a helper method to define mock.On call -// - _a0 *resource.BulkRequest -func (_e *BulkStore_BulkProcessClient_Expecter) Send(_a0 interface{}) *BulkStore_BulkProcessClient_Send_Call { - return &BulkStore_BulkProcessClient_Send_Call{Call: _e.mock.On("Send", _a0)} -} - -func (_c *BulkStore_BulkProcessClient_Send_Call) Run(run func(_a0 *resourcepb.BulkRequest)) *BulkStore_BulkProcessClient_Send_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*resourcepb.BulkRequest)) - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Send_Call) Return(_a0 error) *BulkStore_BulkProcessClient_Send_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Send_Call) RunAndReturn(run func(*resourcepb.BulkRequest) error) *BulkStore_BulkProcessClient_Send_Call { - _c.Call.Return(run) - return _c -} - -// SendMsg provides a mock function with given fields: m -func (_m *BulkStore_BulkProcessClient) SendMsg(m interface{}) error { - ret := _m.Called(m) - - if len(ret) == 0 { - panic("no return value specified for SendMsg") - } - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(m) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// BulkStore_BulkProcessClient_SendMsg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendMsg' -type BulkStore_BulkProcessClient_SendMsg_Call struct { - *mock.Call -} - -// SendMsg is a helper method to define mock.On call -// - m interface{} -func (_e *BulkStore_BulkProcessClient_Expecter) SendMsg(m interface{}) *BulkStore_BulkProcessClient_SendMsg_Call { - return &BulkStore_BulkProcessClient_SendMsg_Call{Call: _e.mock.On("SendMsg", m)} -} - -func (_c *BulkStore_BulkProcessClient_SendMsg_Call) Run(run func(m interface{})) *BulkStore_BulkProcessClient_SendMsg_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(interface{})) - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_SendMsg_Call) Return(_a0 error) *BulkStore_BulkProcessClient_SendMsg_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_SendMsg_Call) RunAndReturn(run func(interface{}) error) *BulkStore_BulkProcessClient_SendMsg_Call { - _c.Call.Return(run) - return _c -} - -// Trailer provides a mock function with no fields -func (_m *BulkStore_BulkProcessClient) Trailer() metadata.MD { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for Trailer") - } - - var r0 metadata.MD - if rf, ok := ret.Get(0).(func() metadata.MD); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(metadata.MD) - } - } - - return r0 -} - -// BulkStore_BulkProcessClient_Trailer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trailer' -type BulkStore_BulkProcessClient_Trailer_Call struct { - *mock.Call -} - -// Trailer is a helper method to define mock.On call -func (_e *BulkStore_BulkProcessClient_Expecter) Trailer() *BulkStore_BulkProcessClient_Trailer_Call { - return &BulkStore_BulkProcessClient_Trailer_Call{Call: _e.mock.On("Trailer")} -} - -func (_c *BulkStore_BulkProcessClient_Trailer_Call) Run(run func()) *BulkStore_BulkProcessClient_Trailer_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Trailer_Call) Return(_a0 metadata.MD) *BulkStore_BulkProcessClient_Trailer_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *BulkStore_BulkProcessClient_Trailer_Call) RunAndReturn(run func() metadata.MD) *BulkStore_BulkProcessClient_Trailer_Call { - _c.Call.Return(run) - return _c -} - -// NewBulkStore_BulkProcessClient creates a new instance of BulkStore_BulkProcessClient. 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 NewBulkStore_BulkProcessClient(t interface { - mock.TestingT - Cleanup(func()) -}) *BulkStore_BulkProcessClient { - mock := &BulkStore_BulkProcessClient{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} 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 deleted file mode 100644 index 9fa21e12ab0..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/mock_bulk_store_client.go +++ /dev/null @@ -1,113 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package migrate - -import ( - context "context" - - grpc "google.golang.org/grpc" - - mock "github.com/stretchr/testify/mock" - - resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -// MockBulkStoreClient is an autogenerated mock type for the BulkStoreClient type -type MockBulkStoreClient struct { - mock.Mock -} - -type MockBulkStoreClient_Expecter struct { - mock *mock.Mock -} - -func (_m *MockBulkStoreClient) EXPECT() *MockBulkStoreClient_Expecter { - return &MockBulkStoreClient_Expecter{mock: &_m.Mock} -} - -// BulkProcess provides a mock function with given fields: ctx, opts -func (_m *MockBulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { - _va := make([]interface{}, len(opts)) - for _i := range opts { - _va[_i] = opts[_i] - } - var _ca []interface{} - _ca = append(_ca, ctx) - _ca = append(_ca, _va...) - ret := _m.Called(_ca...) - - if len(ret) == 0 { - panic("no return value specified for BulkProcess") - } - - var r0 resourcepb.BulkStore_BulkProcessClient - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error)); ok { - return rf(ctx, opts...) - } - if rf, ok := ret.Get(0).(func(context.Context, ...grpc.CallOption) resourcepb.BulkStore_BulkProcessClient); ok { - r0 = rf(ctx, opts...) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(resourcepb.BulkStore_BulkProcessClient) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, ...grpc.CallOption) error); ok { - r1 = rf(ctx, opts...) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockBulkStoreClient_BulkProcess_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BulkProcess' -type MockBulkStoreClient_BulkProcess_Call struct { - *mock.Call -} - -// BulkProcess is a helper method to define mock.On call -// - ctx context.Context -// - opts ...grpc.CallOption -func (_e *MockBulkStoreClient_Expecter) BulkProcess(ctx interface{}, opts ...interface{}) *MockBulkStoreClient_BulkProcess_Call { - return &MockBulkStoreClient_BulkProcess_Call{Call: _e.mock.On("BulkProcess", - append([]interface{}{ctx}, opts...)...)} -} - -func (_c *MockBulkStoreClient_BulkProcess_Call) Run(run func(ctx context.Context, opts ...grpc.CallOption)) *MockBulkStoreClient_BulkProcess_Call { - _c.Call.Run(func(args mock.Arguments) { - variadicArgs := make([]grpc.CallOption, len(args)-1) - for i, a := range args[1:] { - if a != nil { - variadicArgs[i] = a.(grpc.CallOption) - } - } - run(args[0].(context.Context), variadicArgs...) - }) - return _c -} - -func (_c *MockBulkStoreClient_BulkProcess_Call) Return(_a0 resourcepb.BulkStore_BulkProcessClient, _a1 error) *MockBulkStoreClient_BulkProcess_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockBulkStoreClient_BulkProcess_Call) RunAndReturn(run func(context.Context, ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error)) *MockBulkStoreClient_BulkProcess_Call { - _c.Call.Return(run) - return _c -} - -// NewMockBulkStoreClient creates a new instance of MockBulkStoreClient. 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 NewMockBulkStoreClient(t interface { - mock.TestingT - Cleanup(func()) -}) *MockBulkStoreClient { - mock := &MockBulkStoreClient{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} 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 deleted file mode 100644 index c55c4e22508..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/mock_legacy_resources_migrator.go +++ /dev/null @@ -1,91 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package migrate - -import ( - context "context" - - jobs "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" - mock "github.com/stretchr/testify/mock" - - repository "github.com/grafana/grafana/apps/provisioning/pkg/repository" - - v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" -) - -// MockLegacyResourcesMigrator is an autogenerated mock type for the LegacyResourcesMigrator type -type MockLegacyResourcesMigrator struct { - mock.Mock -} - -type MockLegacyResourcesMigrator_Expecter struct { - mock *mock.Mock -} - -func (_m *MockLegacyResourcesMigrator) EXPECT() *MockLegacyResourcesMigrator_Expecter { - return &MockLegacyResourcesMigrator_Expecter{mock: &_m.Mock} -} - -// Migrate provides a mock function with given fields: ctx, rw, namespace, opts, progress -func (_m *MockLegacyResourcesMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts v0alpha1.MigrateJobOptions, progress jobs.JobProgressRecorder) error { - ret := _m.Called(ctx, rw, namespace, opts, progress) - - if len(ret) == 0 { - panic("no return value specified for Migrate") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter, string, v0alpha1.MigrateJobOptions, jobs.JobProgressRecorder) error); ok { - r0 = rf(ctx, rw, namespace, opts, progress) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockLegacyResourcesMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate' -type MockLegacyResourcesMigrator_Migrate_Call struct { - *mock.Call -} - -// Migrate is a helper method to define mock.On call -// - ctx context.Context -// - rw repository.ReaderWriter -// - namespace string -// - opts v0alpha1.MigrateJobOptions -// - progress jobs.JobProgressRecorder -func (_e *MockLegacyResourcesMigrator_Expecter) Migrate(ctx interface{}, rw interface{}, namespace interface{}, opts interface{}, progress interface{}) *MockLegacyResourcesMigrator_Migrate_Call { - return &MockLegacyResourcesMigrator_Migrate_Call{Call: _e.mock.On("Migrate", ctx, rw, namespace, opts, progress)} -} - -func (_c *MockLegacyResourcesMigrator_Migrate_Call) Run(run func(ctx context.Context, rw repository.ReaderWriter, namespace string, opts v0alpha1.MigrateJobOptions, progress jobs.JobProgressRecorder)) *MockLegacyResourcesMigrator_Migrate_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(repository.ReaderWriter), args[2].(string), args[3].(v0alpha1.MigrateJobOptions), args[4].(jobs.JobProgressRecorder)) - }) - return _c -} - -func (_c *MockLegacyResourcesMigrator_Migrate_Call) Return(_a0 error) *MockLegacyResourcesMigrator_Migrate_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockLegacyResourcesMigrator_Migrate_Call) RunAndReturn(run func(context.Context, repository.ReaderWriter, string, v0alpha1.MigrateJobOptions, jobs.JobProgressRecorder) error) *MockLegacyResourcesMigrator_Migrate_Call { - _c.Call.Return(run) - return _c -} - -// NewMockLegacyResourcesMigrator creates a new instance of MockLegacyResourcesMigrator. 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 NewMockLegacyResourcesMigrator(t interface { - mock.TestingT - Cleanup(func()) -}) *MockLegacyResourcesMigrator { - mock := &MockLegacyResourcesMigrator{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_migrator.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_migrator.go index f7bc66ca676..53050b99de1 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/mock_migrator.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/mock_migrator.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.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 2b168bfa202..1ae1afa8ccf 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.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package migrate diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_storage_swapper.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_storage_swapper.go deleted file mode 100644 index d3073c2566b..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/mock_storage_swapper.go +++ /dev/null @@ -1,129 +0,0 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. - -package migrate - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MockStorageSwapper is an autogenerated mock type for the StorageSwapper type -type MockStorageSwapper struct { - mock.Mock -} - -type MockStorageSwapper_Expecter struct { - mock *mock.Mock -} - -func (_m *MockStorageSwapper) EXPECT() *MockStorageSwapper_Expecter { - return &MockStorageSwapper_Expecter{mock: &_m.Mock} -} - -// StopReadingUnifiedStorage provides a mock function with given fields: ctx -func (_m *MockStorageSwapper) StopReadingUnifiedStorage(ctx context.Context) error { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for StopReadingUnifiedStorage") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(ctx) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockStorageSwapper_StopReadingUnifiedStorage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopReadingUnifiedStorage' -type MockStorageSwapper_StopReadingUnifiedStorage_Call struct { - *mock.Call -} - -// StopReadingUnifiedStorage is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockStorageSwapper_Expecter) StopReadingUnifiedStorage(ctx interface{}) *MockStorageSwapper_StopReadingUnifiedStorage_Call { - return &MockStorageSwapper_StopReadingUnifiedStorage_Call{Call: _e.mock.On("StopReadingUnifiedStorage", ctx)} -} - -func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) Run(run func(ctx context.Context)) *MockStorageSwapper_StopReadingUnifiedStorage_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) Return(_a0 error) *MockStorageSwapper_StopReadingUnifiedStorage_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) RunAndReturn(run func(context.Context) error) *MockStorageSwapper_StopReadingUnifiedStorage_Call { - _c.Call.Return(run) - return _c -} - -// WipeUnifiedAndSetMigratedFlag provides a mock function with given fields: ctx, namespace -func (_m *MockStorageSwapper) WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error { - ret := _m.Called(ctx, namespace) - - if len(ret) == 0 { - panic("no return value specified for WipeUnifiedAndSetMigratedFlag") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, namespace) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WipeUnifiedAndSetMigratedFlag' -type MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call struct { - *mock.Call -} - -// WipeUnifiedAndSetMigratedFlag is a helper method to define mock.On call -// - ctx context.Context -// - namespace string -func (_e *MockStorageSwapper_Expecter) WipeUnifiedAndSetMigratedFlag(ctx interface{}, namespace interface{}) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call { - return &MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call{Call: _e.mock.On("WipeUnifiedAndSetMigratedFlag", ctx, namespace)} -} - -func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) Run(run func(ctx context.Context, namespace string)) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) - }) - return _c -} - -func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) Return(_a0 error) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) RunAndReturn(run func(context.Context, string) error) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call { - _c.Call.Return(run) - return _c -} - -// NewMockStorageSwapper creates a new instance of MockStorageSwapper. 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 NewMockStorageSwapper(t interface { - mock.TestingT - Cleanup(func()) -}) *MockStorageSwapper { - mock := &MockStorageSwapper{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_stage_fn.go b/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_stage_fn.go new file mode 100644 index 00000000000..3281004d695 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/migrate/mock_wrap_with_stage_fn.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package migrate + +import ( + context "context" + + repository "github.com/grafana/grafana/apps/provisioning/pkg/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/migrate/storage.go b/pkg/registry/apis/provisioning/jobs/migrate/storage.go deleted file mode 100644 index 6d04eaa8c88..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/storage.go +++ /dev/null @@ -1,102 +0,0 @@ -package migrate - -import ( - "context" - "fmt" - "time" - - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" - - "github.com/grafana/grafana-app-sdk/logging" - - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -//go:generate mockery --name BulkStoreClient --structname MockBulkStoreClient --inpackage --filename mock_bulk_store_client.go --with-expecter -//go:generate mockery --name=BulkStore_BulkProcessClient --srcpkg=github.com/grafana/grafana/pkg/storage/unified/resource --output=. --outpkg=migrate --filename=mock_bulk_process_client.go --with-expecter -type BulkStoreClient interface { - BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) -} - -//go:generate mockery --name StorageSwapper --structname MockStorageSwapper --inpackage --filename mock_storage_swapper.go --with-expecter -type StorageSwapper interface { - StopReadingUnifiedStorage(ctx context.Context) error - WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error -} - -type storageSwapper struct { - // Direct access to unified storage... use carefully! - bulk BulkStoreClient - dual dualwrite.Service -} - -func NewStorageSwapper(bulk BulkStoreClient, dual dualwrite.Service) StorageSwapper { - return &storageSwapper{ - bulk: bulk, - dual: dual, - } -} - -func (s *storageSwapper) StopReadingUnifiedStorage(ctx context.Context) error { - // FIXME: dual writer is not namespaced which means that we would consider all namespaces migrated - // after one migrates - for _, gr := range resources.SupportedProvisioningResources { - status, _ := s.dual.Status(ctx, gr.GroupResource()) - status.ReadUnified = false - status.Migrated = 0 - status.Migrating = 0 - _, err := s.dual.Update(ctx, status) - if err != nil { - return err - } - } - - return nil -} - -func (s *storageSwapper) WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error { - for _, gr := range resources.SupportedProvisioningResources { - status, _ := s.dual.Status(ctx, gr.GroupResource()) - if status.ReadUnified { - return fmt.Errorf("unexpected state - already using unified storage for: %s", gr) - } - if status.Migrating > 0 { - if time.Since(time.UnixMilli(status.Migrating)) < time.Second*30 { - return fmt.Errorf("another migration job is running for: %s", gr) - } - } - settings := resource.BulkSettings{ - RebuildCollection: true, // wipes everything in the collection - Collection: []*resourcepb.ResourceKey{{ - Namespace: namespace, - Group: gr.Group, - Resource: gr.Resource, - }}, - } - ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) - stream, err := s.bulk.BulkProcess(ctx) - if err != nil { - return fmt.Errorf("error clearing unified %s / %w", gr, err) - } - stats, err := stream.CloseAndRecv() - if err != nil { - return fmt.Errorf("error clearing unified %s / %w", gr, err) - } - logger := logging.FromContext(ctx) - logger.Error("cleared unified storage", "stats", stats) - - status.Migrated = time.Now().UnixMilli() // but not really... since the sync is starting - status.ReadUnified = true - status.WriteLegacy = false // keep legacy "clean" - _, err = s.dual.Update(ctx, status) - if err != nil { - return err - } - } - - return nil -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/storage_test.go b/pkg/registry/apis/provisioning/jobs/migrate/storage_test.go deleted file mode 100644 index 5e1adadcc2d..00000000000 --- a/pkg/registry/apis/provisioning/jobs/migrate/storage_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package migrate - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" - - "google.golang.org/grpc/metadata" -) - -func TestStorageSwapper_StopReadingUnifiedStorage(t *testing.T) { - tests := []struct { - name string - setupMocks func(*MockBulkStoreClient, *dualwrite.MockService) - expectedError string - }{ - { - name: "should update status for all resources", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - for _, gr := range resources.SupportedProvisioningResources { - status := dualwrite.StorageStatus{ - ReadUnified: true, - Migrated: 123, - Migrating: 456, - } - dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil) - dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool { - return !status.ReadUnified && status.Migrated == 0 && status.Migrating == 0 - })).Return(dualwrite.StorageStatus{}, nil) - } - }, - }, - { - name: "should fail if status update fails", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil) - dual.On("Update", mock.Anything, mock.Anything).Return(dualwrite.StorageStatus{}, errors.New("update failed")) - }, - expectedError: "update failed", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bulk := NewMockBulkStoreClient(t) - dual := dualwrite.NewMockService(t) - - if tt.setupMocks != nil { - tt.setupMocks(bulk, dual) - } - - swapper := NewStorageSwapper(bulk, dual) - err := swapper.StopReadingUnifiedStorage(context.Background()) - - if tt.expectedError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestStorageSwapper_WipeUnifiedAndSetMigratedFlag(t *testing.T) { - tests := []struct { - name string - setupMocks func(*MockBulkStoreClient, *dualwrite.MockService) - expectedError string - }{ - { - name: "should fail if already using unified storage", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - status := dualwrite.StorageStatus{ - ReadUnified: true, - } - dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil) - }, - expectedError: "unexpected state - already using unified storage", - }, - { - name: "should fail if migration is in progress", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - status := dualwrite.StorageStatus{ - ReadUnified: false, - Migrating: time.Now().UnixMilli(), - } - dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil) - }, - expectedError: "another migration job is running", - }, - { - name: "should fail if bulk process fails", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil) - bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(nil, errors.New("bulk process failed")) - }, - expectedError: "error clearing unified", - }, - { - name: "should fail if status update fails after bulk process", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil) - - mockStream := NewBulkStore_BulkProcessClient(t) - mockStream.On("CloseAndRecv").Return(&resourcepb.BulkResponse{}, nil) - bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(mockStream, nil) - - dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool { - return status.ReadUnified && !status.WriteLegacy && status.Migrated > 0 - })).Return(dualwrite.StorageStatus{}, errors.New("update failed")) - }, - expectedError: "update failed", - }, - { - name: "should fail if bulk process stream close fails", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - gr := resources.SupportedProvisioningResources[0] - dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil) - - mockStream := NewBulkStore_BulkProcessClient(t) - mockStream.On("CloseAndRecv").Return(nil, errors.New("stream close failed")) - bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(mockStream, nil) - }, - expectedError: "error clearing unified", - }, - { - name: "should succeed with complete workflow", - setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) { - for _, gr := range resources.SupportedProvisioningResources { - dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil) - - mockStream := NewBulkStore_BulkProcessClient(t) - mockStream.On("CloseAndRecv").Return(&resourcepb.BulkResponse{}, nil) - bulk.On("BulkProcess", mock.MatchedBy(func(ctx context.Context) bool { - md, ok := metadata.FromOutgoingContext(ctx) - if !ok { - return false - } - - //nolint:errcheck // hits the err != nil gotcha - settings, _ := resource.NewBulkSettings(md) - if !settings.RebuildCollection { - return false - } - if len(settings.Collection) != 1 { - return false - } - if settings.Collection[0].Namespace != "test-namespace" { - return false - } - if settings.Collection[0].Group != gr.Group { - return false - } - if settings.Collection[0].Resource != gr.Resource { - return false - } - - return true - }), mock.Anything).Return(mockStream, nil) - - dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool { - return status.ReadUnified && !status.WriteLegacy && status.Migrated > 0 - })).Return(dualwrite.StorageStatus{}, nil) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bulk := NewMockBulkStoreClient(t) - dual := dualwrite.NewMockService(t) - - if tt.setupMocks != nil { - tt.setupMocks(bulk, dual) - } - - swapper := NewStorageSwapper(bulk, dual) - err := swapper.WipeUnifiedAndSetMigratedFlag(context.Background(), "test-namespace") - - if tt.expectedError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tt.expectedError) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go index edd3a72f1bd..35c4de298f2 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go @@ -33,23 +33,7 @@ func NewUnifiedStorageMigrator( func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error { namespace := repo.Config().GetNamespace() - // For folder-type repositories, only run sync (skip export and cleaner) - if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder { - progress.SetMessage(ctx, "pull resources") - syncJob := provisioning.Job{ - Spec: provisioning.JobSpec{ - Pull: &provisioning.SyncJobOptions{ - Incremental: false, - }, - }, - } - if err := m.syncWorker.Process(ctx, repo, syncJob, progress); err != nil { - return fmt.Errorf("pull resources: %w", err) - } - return nil - } - - // For instance-type repositories, run the full workflow: export -> sync -> clean + // Export resources first (for both folder and instance sync) progress.SetMessage(ctx, "export resources") progress.StrictMaxErrors(1) // strict as we want the entire instance to be managed @@ -67,6 +51,7 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re // Reset the results after the export as pull will operate on the same resources progress.ResetResults() + // Pull resources from the repository progress.SetMessage(ctx, "pull resources") syncJob := provisioning.Job{ Spec: provisioning.JobSpec{ @@ -79,9 +64,12 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re return fmt.Errorf("pull resources: %w", err) } - progress.SetMessage(ctx, "clean namespace") - if err := m.namespaceCleaner.Clean(ctx, namespace, progress); err != nil { - return fmt.Errorf("clean namespace: %w", err) + // For instance-type repositories, also clean the namespace + if repo.Config().Spec.Sync.Target != provisioning.SyncTargetTypeFolder { + progress.SetMessage(ctx, "clean namespace") + if err := m.namespaceCleaner.Clean(ctx, namespace, progress); err != nil { + return fmt.Errorf("clean namespace: %w", err) + } } return nil diff --git a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go index d509c1c2f81..d84e95fcebd 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go @@ -134,7 +134,7 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { expectedError: "", }, { - name: "should only run sync for folder-type repositories", + name: "should run export and sync for folder-type repositories", setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { rw.On("Config").Return(&provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ @@ -147,9 +147,15 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { }, }, }) - // Export should be skipped - no export-related mocks - // Cleaner should also be skipped - no cleaner-related mocks - // Only sync job should run + // Export should run for folder-type repositories + pr.On("SetMessage", mock.Anything, "export resources").Return() + pr.On("StrictMaxErrors", 1).Return() + ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Push != nil + }), pr).Return(nil) + pr.On("ResetResults").Return() + // Cleaner should be skipped - no cleaner-related mocks + // Sync job should run pr.On("SetMessage", mock.Anything, "pull resources").Return() sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { return job.Spec.Pull != nil && !job.Spec.Pull.Incremental @@ -171,7 +177,14 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { }, }, }) - // Only sync job should run and fail + // Export should run first + pr.On("SetMessage", mock.Anything, "export resources").Return() + pr.On("StrictMaxErrors", 1).Return() + ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Push != nil + }), pr).Return(nil) + pr.On("ResetResults").Return() + // Sync job should run and fail pr.On("SetMessage", mock.Anything, "pull resources").Return() sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { return job.Spec.Pull != nil && !job.Spec.Pull.Incremental diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker.go b/pkg/registry/apis/provisioning/jobs/migrate/worker.go index 9de5d4015e8..e1b4e916d10 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker.go @@ -7,7 +7,6 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) //go:generate mockery --name Migrator --structname MockMigrator --inpackage --filename mock_migrator.go --with-expecter @@ -16,8 +15,6 @@ type Migrator interface { } type MigrationWorker struct { - storageStatus dualwrite.Service - legacyMigrator Migrator unifiedMigrator Migrator } @@ -27,16 +24,9 @@ func NewMigrationWorkerFromUnified(unifiedMigrator Migrator) *MigrationWorker { } } -// HACK: we should decouple the implementation of these two -func NewMigrationWorker( - legacyMigrator Migrator, - unifiedMigrator Migrator, - storageStatus dualwrite.Service, -) *MigrationWorker { +func NewMigrationWorker(unifiedMigrator Migrator) *MigrationWorker { return &MigrationWorker{ unifiedMigrator: unifiedMigrator, - legacyMigrator: legacyMigrator, - storageStatus: storageStatus, } } @@ -56,28 +46,5 @@ func (w *MigrationWorker) Process(ctx context.Context, repo repository.Repositor return errors.New("migration job submitted targeting repository that is not a ReaderWriter") } - if options.History { - if repo.Config().Spec.Type != provisioning.GitHubRepositoryType { - return errors.New("history is only supported for github repositories") - } - } - - // Block migrate for legacy resources if repository type is folder - if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder { - // HACK: we should not have to check for storage existence here - if w.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) { - return errors.New("migration of legacy resources is not supported for folder-type repositories") - } - } - - // HACK: we should not have to check for storage existence here - if w.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) { - return w.legacyMigrator.Migrate(ctx, rw, *options, progress) - } - - if options.History { - return errors.New("history is not yet supported in unified storage") - } - return w.unifiedMigrator.Migrate(ctx, rw, *options, progress) } diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go index 962a69a8f54..b8171a2df9d 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go @@ -2,7 +2,6 @@ package migrate import ( "context" - "errors" "testing" "github.com/stretchr/testify/assert" @@ -11,9 +10,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/apps/provisioning/pkg/repository/local" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) func TestMigrationWorker_IsSupported(t *testing.T) { @@ -42,7 +39,7 @@ func TestMigrationWorker_IsSupported(t *testing.T) { }, } - worker := NewMigrationWorker(nil, nil, nil) + worker := NewMigrationWorker(nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -53,7 +50,7 @@ func TestMigrationWorker_IsSupported(t *testing.T) { } func TestMigrationWorker_ProcessNotReaderWriter(t *testing.T) { - worker := NewMigrationWorker(nil, nil, nil) + worker := NewMigrationWorker(NewMockMigrator(t)) job := provisioning.Job{ Spec: provisioning.JobSpec{ Action: provisioning.JobActionMigrate, @@ -68,56 +65,13 @@ func TestMigrationWorker_ProcessNotReaderWriter(t *testing.T) { require.EqualError(t, err, "migration job submitted targeting repository that is not a ReaderWriter") } -func TestMigrationWorker_WithHistory(t *testing.T) { - fakeDualwrite := dualwrite.NewMockService(t) - fakeDualwrite.On("ReadFromUnified", mock.Anything, mock.Anything). - Maybe().Return(true, nil) // using unified storage - - worker := NewMigrationWorker(nil, nil, fakeDualwrite) - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionMigrate, - Migrate: &provisioning.MigrateJobOptions{ - History: true, - }, - }, - } - - t.Run("fail local", func(t *testing.T) { - progressRecorder := jobs.NewMockJobProgressRecorder(t) - progressRecorder.On("SetTotal", mock.Anything, 10).Return() - - repo := local.NewRepository(&provisioning.Repository{}, nil) - err := worker.Process(context.Background(), repo, job, progressRecorder) - require.EqualError(t, err, "history is only supported for github repositories") - }) - - t.Run("fail unified", func(t *testing.T) { - progressRecorder := jobs.NewMockJobProgressRecorder(t) - progressRecorder.On("SetTotal", mock.Anything, 10).Return() - - repo := repository.NewMockRepository(t) - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Type: provisioning.GitHubRepositoryType, - GitHub: &provisioning.GitHubRepositoryConfig{ - URL: "empty", // not valid - }, - }, - }) - err := worker.Process(context.Background(), repo, job, progressRecorder) - require.EqualError(t, err, "history is not yet supported in unified storage") - }) -} - func TestMigrationWorker_Process(t *testing.T) { tests := []struct { - name string - setupMocks func(*MockMigrator, *MockMigrator, *dualwrite.MockService, *jobs.MockJobProgressRecorder) - setupRepo func(*repository.MockRepository) - job provisioning.Job - expectedError string - isLegacyActive bool + name string + setupMocks func(*MockMigrator, *jobs.MockJobProgressRecorder) + setupRepo func(*repository.MockRepository) + job provisioning.Job + expectedError string }{ { name: "should fail when migrate settings are missing", @@ -127,7 +81,7 @@ func TestMigrationWorker_Process(t *testing.T) { Migrate: nil, }, }, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) { }, setupRepo: func(repo *repository.MockRepository) { // No Config() call expected since we fail before that @@ -135,150 +89,35 @@ func TestMigrationWorker_Process(t *testing.T) { expectedError: "missing migrate settings", }, { - name: "should use legacy migrator when legacy storage is active", + name: "should use unified storage migrator for instance-type repositories", job: provisioning.Job{ Spec: provisioning.JobSpec{ Action: provisioning.JobActionMigrate, Migrate: &provisioning.MigrateJobOptions{}, }, }, - isLegacyActive: true, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) { pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) - lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) - }, - setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeInstance, - }, - }, - }) - }, - }, - { - name: "should use unified storage migrator when legacy storage is not active", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionMigrate, - Migrate: &provisioning.MigrateJobOptions{}, - }, - }, - isLegacyActive: false, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { - pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil) um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) }, setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeInstance, - }, - }, - }) + // No Config() call needed anymore }, }, { - name: "should propagate migrator errors", + name: "should allow migration for folder-type repositories", job: provisioning.Job{ Spec: provisioning.JobSpec{ Action: provisioning.JobActionMigrate, Migrate: &provisioning.MigrateJobOptions{}, }, }, - isLegacyActive: true, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) { pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) - lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("migration failed")) - }, - setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeInstance, - }, - }, - }) - }, - expectedError: "migration failed", - }, - { - name: "should block migration of legacy resources for folder-type repositories", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionMigrate, - Migrate: &provisioning.MigrateJobOptions{}, - }, - }, - isLegacyActive: true, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { - pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) - // legacyMigrator should not be called as we block before reaching it - }, - setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeFolder, - }, - }, - }) - }, - expectedError: "migration of legacy resources is not supported for folder-type repositories", - }, - { - name: "should allow migration of legacy resources for instance-type repositories", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionMigrate, - Migrate: &provisioning.MigrateJobOptions{}, - }, - }, - isLegacyActive: true, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { - pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) - lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) - }, - setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeInstance, - }, - }, - }) - }, - expectedError: "", - }, - { - name: "should allow migration for folder-type repositories when legacy storage is not active", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionMigrate, - Migrate: &provisioning.MigrateJobOptions{}, - }, - }, - isLegacyActive: false, - setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { - pr.On("SetTotal", mock.Anything, 10).Return() - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil) um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) }, setupRepo: func(repo *repository.MockRepository) { - repo.On("Config").Return(&provisioning.Repository{ - Spec: provisioning.RepositorySpec{ - Sync: provisioning.SyncOptions{ - Target: provisioning.SyncTargetTypeFolder, - }, - }, - }) + // No Config() call needed anymore }, expectedError: "", }, @@ -286,15 +125,13 @@ func TestMigrationWorker_Process(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - legacyMigrator := NewMockMigrator(t) unifiedMigrator := NewMockMigrator(t) - dualWriteService := dualwrite.NewMockService(t) progressRecorder := jobs.NewMockJobProgressRecorder(t) - worker := NewMigrationWorker(legacyMigrator, unifiedMigrator, dualWriteService) + worker := NewMigrationWorker(unifiedMigrator) if tt.setupMocks != nil { - tt.setupMocks(legacyMigrator, unifiedMigrator, dualWriteService, progressRecorder) + tt.setupMocks(unifiedMigrator, progressRecorder) } rw := repository.NewMockRepository(t) @@ -310,7 +147,7 @@ func TestMigrationWorker_Process(t *testing.T) { require.NoError(t, err) } - mock.AssertExpectationsForObjects(t, legacyMigrator, unifiedMigrator, dualWriteService, progressRecorder, rw) + mock.AssertExpectationsForObjects(t, unifiedMigrator, progressRecorder, rw) }) } } diff --git a/pkg/registry/apis/provisioning/jobs/progress_fn_mock.go b/pkg/registry/apis/provisioning/jobs/progress_fn_mock.go index 8c68bec33f7..9fde7042601 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_fn_mock.go +++ b/pkg/registry/apis/provisioning/jobs/progress_fn_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package jobs diff --git a/pkg/registry/apis/provisioning/jobs/sync/sync.go b/pkg/registry/apis/provisioning/jobs/sync/sync.go index 7f677a8ce3b..6fec2783a6a 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/sync.go +++ b/pkg/registry/apis/provisioning/jobs/sync/sync.go @@ -14,7 +14,7 @@ import ( //go:generate mockery --name FullSyncFn --structname MockFullSyncFn --inpackage --filename full_sync_fn_mock.go --with-expecter type FullSyncFn func(ctx context.Context, repo repository.Reader, compare CompareFn, clients resources.ResourceClients, currentRef string, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, maxSyncWorkers int, metrics jobs.JobMetrics) error -//go:generate mockery --name CompareFn --structname MockCompareFn --inpackage --filename compare_fn_mock.go --with-expecter +//go:generate mockery -name CompareFn --structname MockCompareFn --inpackage --filename compare_fn_mock.go --with-expecter type CompareFn func(ctx context.Context, repo repository.Reader, repositoryResources resources.RepositoryResources, ref string) ([]ResourceFileChange, error) //go:generate mockery --name IncrementalSyncFn --structname MockIncrementalSyncFn --inpackage --filename incremental_sync_fn_mock.go --with-expecter diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker.go b/pkg/registry/apis/provisioning/jobs/sync/worker.go index 05e6340b193..367ed672bd2 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/worker.go +++ b/pkg/registry/apis/provisioning/jobs/sync/worker.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/registry/apis/provisioning/utils" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -29,9 +28,6 @@ type SyncWorker struct { // ResourceClients for the repository repositoryResources resources.RepositoryResourcesFactory - // Check if the system is using unified storage - storageStatus dualwrite.Service - // Patch status for the repository patchStatus RepositoryPatchFn @@ -48,7 +44,6 @@ type SyncWorker struct { func NewSyncWorker( clients resources.ClientFactory, repositoryResources resources.RepositoryResourcesFactory, - storageStatus dualwrite.Service, patchStatus RepositoryPatchFn, syncer Syncer, metrics jobs.JobMetrics, @@ -59,7 +54,6 @@ func NewSyncWorker( clients: clients, repositoryResources: repositoryResources, patchStatus: patchStatus, - storageStatus: storageStatus, syncer: syncer, metrics: metrics, tracer: tracer, @@ -96,13 +90,6 @@ func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, jo ) }() - // Check if we are onboarding from legacy storage - // HACK -- this should be handled outside of this worker - if r.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) { - err := fmt.Errorf("sync not supported until storage has migrated") - return tracing.Error(span, err) - } - rw, ok := repo.(repository.ReaderWriter) if !ok { err := fmt.Errorf("sync job submitted for repository that does not support read-write") diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker_test.go b/pkg/registry/apis/provisioning/jobs/sync/worker_test.go index a5013539957..34d32372936 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/worker_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -46,7 +45,7 @@ func TestSyncWorker_IsSupported(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - worker := NewSyncWorker(nil, nil, nil, nil, nil, metrics, tracing.NewNoopTracerService(), 10) + worker := NewSyncWorker(nil, nil, nil, nil, metrics, tracing.NewNoopTracerService(), 10) result := worker.IsSupported(context.Background(), tt.job) require.Equal(t, tt.expected, result) }) @@ -63,9 +62,7 @@ func TestSyncWorker_ProcessNotReaderWriter(t *testing.T) { Title: "test-repo", }, }) - fakeDualwrite := dualwrite.NewMockService(t) - fakeDualwrite.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - worker := NewSyncWorker(nil, nil, fakeDualwrite, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()), tracing.NewNoopTracerService(), 10) + worker := NewSyncWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()), tracing.NewNoopTracerService(), 10) err := worker.Process(context.Background(), repo, provisioning.Job{}, jobs.NewMockJobProgressRecorder(t)) require.EqualError(t, err, "sync job submitted for repository that does not support read-write") } @@ -73,31 +70,13 @@ func TestSyncWorker_ProcessNotReaderWriter(t *testing.T) { func TestSyncWorker_Process(t *testing.T) { tests := []struct { name string - setupMocks func(*resources.MockClientFactory, *resources.MockRepositoryResourcesFactory, *dualwrite.MockService, *MockRepositoryPatchFn, *MockSyncer, *mockReaderWriter, *jobs.MockJobProgressRecorder) + setupMocks func(*resources.MockClientFactory, *resources.MockRepositoryResourcesFactory, *MockRepositoryPatchFn, *MockSyncer, *mockReaderWriter, *jobs.MockJobProgressRecorder) expectedError string expectedStatus *provisioning.SyncStatus }{ - { - name: "legacy storage not migrated", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { - rw.MockRepository.On("Config").Return(&provisioning.Repository{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-repo", - }, - Spec: provisioning.RepositorySpec{ - Title: "test-repo", - }, - }) - - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil).Twice() - }, - expectedError: "sync not supported until storage has migrated", - }, { name: "failed initial status patching", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { // Setup repository config with existing LastRef repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ @@ -132,7 +111,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "failed getting repository resources", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { // Setup repository config repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ @@ -149,9 +128,6 @@ func TestSyncWorker_Process(t *testing.T) { } rw.MockRepository.On("Config").Return(repoConfig) - // Storage is migrated - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update succeeds - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -168,7 +144,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "failed getting clients for namespace", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { // Setup repository config repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ @@ -186,9 +162,6 @@ func TestSyncWorker_Process(t *testing.T) { } rw.MockRepository.On("Config").Return(repoConfig) - // Storage is migrated - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update succeeds - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -208,7 +181,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "successful sync", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -222,9 +195,6 @@ func TestSyncWorker_Process(t *testing.T) { } rw.MockRepository.On("Config").Return(repoConfig) - // Storage is migrated - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -261,7 +231,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "failed sync", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -275,9 +245,6 @@ func TestSyncWorker_Process(t *testing.T) { } rw.MockRepository.On("Config").Return(repoConfig) - // Storage is migrated - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() - // Initial status update - expect granular patches pr.On("SetMessage", mock.Anything, "update sync status at start").Return() rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -315,7 +282,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "stats call fails", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -323,7 +290,6 @@ func TestSyncWorker_Process(t *testing.T) { }, } rw.MockRepository.On("Config").Return(repoConfig) - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() mockRepoResources := resources.NewMockRepositoryResources(t) mockRepoResources.On("Stats", mock.Anything).Return(nil, errors.New("stats error")) @@ -344,7 +310,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "stats returns nil stats and nil error", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -352,7 +318,6 @@ func TestSyncWorker_Process(t *testing.T) { }, } rw.MockRepository.On("Config").Return(repoConfig) - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() mockRepoResources := resources.NewMockRepositoryResources(t) mockRepoResources.On("Stats", mock.Anything).Return(nil, nil) @@ -378,7 +343,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "stats returns one managed stats", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -386,7 +351,6 @@ func TestSyncWorker_Process(t *testing.T) { }, } rw.MockRepository.On("Config").Return(repoConfig) - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() // Initial patch with granular updates rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -439,7 +403,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "stats returns multiple managed stats", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -447,7 +411,6 @@ func TestSyncWorker_Process(t *testing.T) { }, } rw.MockRepository.On("Config").Return(repoConfig) - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() mockRepoResources := resources.NewMockRepositoryResources(t) stats := &provisioning.ResourceStats{ @@ -495,7 +458,7 @@ func TestSyncWorker_Process(t *testing.T) { }, { name: "failed final status patch", - setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { + setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) { repoConfig := &provisioning.Repository{ ObjectMeta: metav1.ObjectMeta{ Name: "test-repo", @@ -503,7 +466,6 @@ func TestSyncWorker_Process(t *testing.T) { }, } rw.MockRepository.On("Config").Return(repoConfig) - ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice() // Initial status patch succeeds - expect granular patches rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() @@ -534,7 +496,6 @@ func TestSyncWorker_Process(t *testing.T) { // Create mocks clientFactory := resources.NewMockClientFactory(t) repoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) - dualwriteService := dualwrite.NewMockService(t) repositoryPatchFn := NewMockRepositoryPatchFn(t) syncer := NewMockSyncer(t) readerWriter := &mockReaderWriter{ @@ -544,13 +505,12 @@ func TestSyncWorker_Process(t *testing.T) { progressRecorder := jobs.NewMockJobProgressRecorder(t) // Setup mocks - tt.setupMocks(clientFactory, repoResourcesFactory, dualwriteService, repositoryPatchFn, syncer, readerWriter, progressRecorder) + tt.setupMocks(clientFactory, repoResourcesFactory, repositoryPatchFn, syncer, readerWriter, progressRecorder) // Create worker worker := NewSyncWorker( clientFactory, repoResourcesFactory, - dualwriteService, repositoryPatchFn.Execute, syncer, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()), diff --git a/pkg/registry/apis/provisioning/jobs/worker_mock.go b/pkg/registry/apis/provisioning/jobs/worker_mock.go index 52f0683fdd8..d669193c166 100644 --- a/pkg/registry/apis/provisioning/jobs/worker_mock.go +++ b/pkg/registry/apis/provisioning/jobs/worker_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package jobs diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index b479dc3f8c8..80c22338ae0 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -28,8 +28,6 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-app-sdk/logging" - dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" - folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" @@ -54,14 +52,12 @@ import ( movepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/move" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync" "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/usage" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -112,7 +108,6 @@ type APIBuilder struct { jobHistoryLoki *jobs.LokiJobHistory resourceLister resources.ResourceLister dashboardAccess legacy.MigrationDashboardAccessor - storageStatus dualwrite.Service unified resource.ResourceClient repoFactory repository.Factory client client.ProvisioningV0alpha1Interface @@ -159,9 +154,9 @@ func NewAPIBuilder( } else { clients = resources.NewClientFactory(configProvider) } + parsers := resources.NewParserFactory(clients) - legacyMigrator := migrations.ProvideUnifiedMigrator(dashboardAccess, unified) - resourceLister := resources.NewResourceListerForMigrations(unified, legacyMigrator, storageStatus) + resourceLister := resources.NewResourceListerForMigrations(unified) b := &APIBuilder{ onlyApiServer: onlyApiServer, @@ -174,7 +169,6 @@ func NewAPIBuilder( repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister), resourceLister: resourceLister, dashboardAccess: dashboardAccess, - storageStatus: storageStatus, unified: unified, access: access, jobHistoryConfig: jobHistoryConfig, @@ -250,6 +244,10 @@ func RegisterAPIService( return nil, nil } + if dualwrite.IsReadingLegacyDashboardsAndFolders(context.Background(), storageStatus) { + return nil, fmt.Errorf("resources are stored in an incompatible data format to use provisioning. Please enable data migration in settings for folders and dashboards by adding the following configuration:\n[unified_storage.folders.folder.grafana.app]\nenableMigration = true\n\n[unified_storage.dashboards.dashboard.grafana.app]\nenableMigration = true\n\nAlternatively, disable provisioning") + } + allowedTargets := []provisioning.SyncTargetType{} for _, target := range cfg.ProvisioningAllowedTargets { allowedTargets = append(allowedTargets, provisioning.SyncTargetType(target)) @@ -762,12 +760,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH go repoInformer.Informer().Run(postStartHookCtx.Done()) go jobInformer.Informer().Run(postStartHookCtx.Done()) - // When starting with an empty instance -- swith to "mode 4+" - err = b.tryRunningOnlyUnifiedStorage() - if err != nil { - return err - } - // Create the repository resources factory repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) { return GetRepositoriesInNamespace(ctx, b.store) @@ -790,29 +782,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH syncWorker := sync.NewSyncWorker( b.clients, b.repositoryResources, - b.storageStatus, b.statusPatcher.Patch, syncer, metrics, b.tracer, 10, ) - signerFactory := signature.NewSignerFactory(b.clients) - legacyResources := migrate.NewLegacyResourcesMigrator( - b.repositoryResources, - b.parsers, - b.dashboardAccess, - signerFactory, - b.clients, - export.ExportAll, - ) - storageSwapper := migrate.NewStorageSwapper(b.unified, b.storageStatus) - legacyMigrator := migrate.NewLegacyMigrator( - legacyResources, - storageSwapper, - syncWorker, - stageIfPossible, - ) cleaner := migrate.NewNamespaceCleaner(b.clients) unifiedStorageMigrator := migrate.NewUnifiedStorageMigrator( @@ -820,12 +795,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH exportWorker, syncWorker, ) - - migrationWorker := migrate.NewMigrationWorker( - legacyMigrator, - unifiedStorageMigrator, - b.storageStatus, - ) + migrationWorker := migrate.NewMigrationWorker(unifiedStorageMigrator) deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources, metrics) moveWorker := movepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources, metrics) @@ -897,7 +867,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.resourceLister, b.clients, b.jobs, - b.storageStatus, b.GetHealthChecker(), b.statusPatcher, b.registry, @@ -1306,65 +1275,6 @@ spec: return oas, nil } -// FIXME: This logic does not belong in provisioning! (but required for now) -// When starting an empty instance, we shift so that we never reference legacy storage -// This should run somewhere else at startup by default (dual writer? dashboards?) -func (b *APIBuilder) tryRunningOnlyUnifiedStorage() error { - ctx := context.Background() - - if !b.storageStatus.ShouldManage(dashboard.DashboardResourceInfo.GroupResource()) { - return nil // not enabled - } - - if !dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus) { - return nil - } - - // Count how many things exist - create a migrator on-demand for this - legacyMigrator := migrations.ProvideUnifiedMigrator(b.dashboardAccess, b.unified) - rsp, err := legacyMigrator.Migrate(ctx, legacy.MigrateOptions{ - Namespace: "default", // FIXME! this works for single org, but need to check multi-org - Resources: []schema.GroupResource{{ - Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE, - }, { - Group: folders.GROUP, Resource: folders.RESOURCE, - }}, - OnlyCount: true, - }) - if err != nil { - return fmt.Errorf("error getting legacy count %w", err) - } - for _, stats := range rsp.Summary { - if stats.Count > 0 { - return nil // something exists we can not just switch - } - } - - logger := logging.DefaultLogger.With("logger", "provisioning startup") - mode5 := func(gr schema.GroupResource) error { - status, _ := b.storageStatus.Status(ctx, gr) - if !status.ReadUnified { - status.ReadUnified = true - status.WriteLegacy = false - status.WriteUnified = true - status.Runtime = false - status.Migrated = time.Now().UnixMilli() - _, err = b.storageStatus.Update(ctx, status) - logger.Info("set unified storage access", "group", gr.Group, "resource", gr.Resource) - return err - } - return nil // already reading unified - } - - if err = mode5(dashboard.DashboardResourceInfo.GroupResource()); err != nil { - return err - } - if err = mode5(folders.FolderResourceInfo.GroupResource()); err != nil { - return err - } - return nil -} - // Helpers for fetching valid Repository objects // TODO: where should the helpers live? diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index b74ab67976a..8b8f4201745 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -197,6 +197,8 @@ func (fm *FolderManager) EnsureFolderTreeExists(ctx context.Context, ref, path s if err != nil && (!errors.Is(err, repository.ErrFileNotFound) && !apierrors.IsNotFound(err)) { return fn(folder, false, fmt.Errorf("check if folder exists before writing: %w", err)) } else if err == nil { + // Folder already exists in repository, add it to tree so resources can find it + fm.tree.Add(folder, parent) return fn(folder, false, nil) } diff --git a/pkg/registry/apis/provisioning/resources/object.go b/pkg/registry/apis/provisioning/resources/object.go index 0a016786410..a51217d08af 100644 --- a/pkg/registry/apis/provisioning/resources/object.go +++ b/pkg/registry/apis/provisioning/resources/object.go @@ -4,15 +4,9 @@ import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" - folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -31,25 +25,16 @@ type ResourceStore interface { } type ResourceListerFromSearch struct { - store ResourceStore - migrator migrations.UnifiedMigrator - storageStatus dualwrite.Service + store ResourceStore } func NewResourceLister(store ResourceStore) ResourceLister { return &ResourceListerFromSearch{store: store} } -// FIXME: the logic about migration and storage should probably be separated from this -func NewResourceListerForMigrations( - store ResourceStore, - migrator migrations.UnifiedMigrator, - storageStatus dualwrite.Service, -) ResourceLister { +func NewResourceListerForMigrations(store ResourceStore) ResourceLister { return &ResourceListerFromSearch{ - store: store, - migrator: migrator, - storageStatus: storageStatus, + store: store, } } @@ -133,37 +118,6 @@ func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, reposit return stats, nil } - // Get the stats based on what a migration could support - if o.storageStatus != nil && o.migrator != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, o.storageStatus) { - rsp, err := o.migrator.Migrate(ctx, legacy.MigrateOptions{ - Namespace: namespace, - Resources: []schema.GroupResource{{ - Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE, - }, { - Group: folders.GROUP, Resource: folders.RESOURCE, - }}, - WithHistory: false, - OnlyCount: true, - }) - if err != nil { - return nil, err - } - for _, v := range rsp.Summary { - stats.Instance = append(stats.Instance, provisioning.ResourceCount{ - Group: v.Group, - Resource: v.Resource, - Count: v.Count, - }) - // Everything is unmanaged in legacy storage - stats.Unmanaged = append(stats.Unmanaged, provisioning.ResourceCount{ - Group: v.Group, - Resource: v.Resource, - Count: v.Count, - }) - } - return stats, nil - } - // Get full instance stats info, err := o.store.GetStats(ctx, &resourcepb.ResourceStatsRequest{ Namespace: namespace, diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go index 4954e6b51b6..8e12c7556cd 100644 --- a/pkg/registry/apis/provisioning/resources/resources.go +++ b/pkg/registry/apis/provisioning/resources/resources.go @@ -180,7 +180,12 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj var ok bool fid, ok = r.folders.Tree().DirPath(folder, rootFolder) if !ok { - return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder) + // HACK: this is a hack to get the folder path without the root folder + // TODO: should we build the tree in a different way? + fid, ok = r.folders.Tree().DirPath(folder, "") + if !ok { + return "", fmt.Errorf("folder %s NOT found in tree", folder) + } } } diff --git a/pkg/registry/apis/provisioning/resources/signature/grafana.go b/pkg/registry/apis/provisioning/resources/signature/grafana.go deleted file mode 100644 index e91497c1245..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/grafana.go +++ /dev/null @@ -1,33 +0,0 @@ -package signature - -import ( - "context" - - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -type grafanaSigner struct{} - -// FIXME: where should we use this default signature? -// NewGrafanaSigner returns a Signer that uses the grafana user as the author -func NewGrafanaSigner() Signer { - return &grafanaSigner{} -} - -func (s *grafanaSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) { - sig := repository.CommitSignature{ - Name: "grafana", - // TODO: should we add email? - // Email: "grafana@grafana.com", - } - - t, err := item.GetUpdatedTimestamp() - if err == nil && t != nil { - sig.When = *t - } else { - sig.When = item.GetCreationTimestamp().Time - } - - return repository.WithAuthorSignature(ctx, sig), nil -} diff --git a/pkg/registry/apis/provisioning/resources/signature/grafana_test.go b/pkg/registry/apis/provisioning/resources/signature/grafana_test.go deleted file mode 100644 index cae5fbf4ebb..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/grafana_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package signature - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -func TestNewGrafanaSigner(t *testing.T) { - signer := NewGrafanaSigner() - require.NotNil(t, signer, "signer should not be nil") - require.IsType(t, &grafanaSigner{}, signer, "signer should be of type *grafanaSigner") -} - -func TestGrafanaSigner_Sign(t *testing.T) { - tests := []struct { - name string - creationTimestamp time.Time - updateTimestampErr error - updatedTimestamp *time.Time - expectedTime time.Time - setupMocks func(meta *utils.MockGrafanaMetaAccessor) - }{ - { - name: "should use creation timestamp when no update timestamp", - creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - updatedTimestamp: ptr(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), - updateTimestampErr: errors.New("failed"), - expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - setupMocks: func(meta *utils.MockGrafanaMetaAccessor) { - meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}) - }, - }, - { - name: "should use creation timestamp when update timestamp is nil", - creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - updatedTimestamp: nil, - expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - setupMocks: func(meta *utils.MockGrafanaMetaAccessor) { - meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}) - }, - }, - { - name: "should use update timestamp when available", - creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), - updatedTimestamp: ptr(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)), - expectedTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - meta := utils.NewMockGrafanaMetaAccessor(t) - var updatedTime *time.Time - if tt.updatedTimestamp != nil { - updatedTime = tt.updatedTimestamp - } - meta.On("GetUpdatedTimestamp").Return(updatedTime, tt.updateTimestampErr) - - if tt.setupMocks != nil { - tt.setupMocks(meta) - } - - signer := NewGrafanaSigner() - ctx := context.Background() - - signedCtx, err := signer.Sign(ctx, meta) - require.NoError(t, err) - - // Verify the signature in the context - sig := repository.GetAuthorSignature(signedCtx) - require.NotNil(t, sig, "signature should be present in context") - require.Equal(t, "grafana", sig.Name) - require.Equal(t, tt.expectedTime, sig.When) - - meta.AssertExpectations(t) - }) - } -} - -func ptr[T any](v T) *T { - return &v -} diff --git a/pkg/registry/apis/provisioning/resources/signature/signature.go b/pkg/registry/apis/provisioning/resources/signature/signature.go deleted file mode 100644 index c0b39f7b325..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/signature.go +++ /dev/null @@ -1,52 +0,0 @@ -package signature - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" -) - -//go:generate mockery --name Signer --structname MockSigner --inpackage --filename signer_mock.go --with-expecter -type Signer interface { - Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) -} - -type SignOptions struct { - Namespace string - History bool -} - -// SignerFactory is a factory for creating Signers -// -//go:generate mockery --name SignerFactory --structname MockSignerFactory --inpackage --filename signature_factory_mock.go --with-expecter -type SignerFactory interface { - New(ctx context.Context, opts SignOptions) (Signer, error) -} - -type signerFactory struct { - clients resources.ClientFactory -} - -func NewSignerFactory(clients resources.ClientFactory) SignerFactory { - return &signerFactory{clients} -} - -func (f *signerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) { - if !opts.History { - return NewGrafanaSigner(), nil - } - - clients, err := f.clients.Clients(ctx, opts.Namespace) - if err != nil { - return nil, fmt.Errorf("get clients: %w", err) - } - - userClient, err := clients.User(ctx) - if err != nil { - return nil, fmt.Errorf("get user client: %w", err) - } - - return NewLoadUsersOnceSigner(userClient), nil -} diff --git a/pkg/registry/apis/provisioning/resources/signature/signature_factory_mock.go b/pkg/registry/apis/provisioning/resources/signature/signature_factory_mock.go deleted file mode 100644 index 80647e0129c..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/signature_factory_mock.go +++ /dev/null @@ -1,95 +0,0 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. - -package signature - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MockSignerFactory is an autogenerated mock type for the SignerFactory type -type MockSignerFactory struct { - mock.Mock -} - -type MockSignerFactory_Expecter struct { - mock *mock.Mock -} - -func (_m *MockSignerFactory) EXPECT() *MockSignerFactory_Expecter { - return &MockSignerFactory_Expecter{mock: &_m.Mock} -} - -// New provides a mock function with given fields: ctx, opts -func (_m *MockSignerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) { - ret := _m.Called(ctx, opts) - - if len(ret) == 0 { - panic("no return value specified for New") - } - - var r0 Signer - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, SignOptions) (Signer, error)); ok { - return rf(ctx, opts) - } - if rf, ok := ret.Get(0).(func(context.Context, SignOptions) Signer); ok { - r0 = rf(ctx, opts) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(Signer) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, SignOptions) error); ok { - r1 = rf(ctx, opts) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockSignerFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New' -type MockSignerFactory_New_Call struct { - *mock.Call -} - -// New is a helper method to define mock.On call -// - ctx context.Context -// - opts SignOptions -func (_e *MockSignerFactory_Expecter) New(ctx interface{}, opts interface{}) *MockSignerFactory_New_Call { - return &MockSignerFactory_New_Call{Call: _e.mock.On("New", ctx, opts)} -} - -func (_c *MockSignerFactory_New_Call) Run(run func(ctx context.Context, opts SignOptions)) *MockSignerFactory_New_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(SignOptions)) - }) - return _c -} - -func (_c *MockSignerFactory_New_Call) Return(_a0 Signer, _a1 error) *MockSignerFactory_New_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockSignerFactory_New_Call) RunAndReturn(run func(context.Context, SignOptions) (Signer, error)) *MockSignerFactory_New_Call { - _c.Call.Return(run) - return _c -} - -// NewMockSignerFactory creates a new instance of MockSignerFactory. 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 NewMockSignerFactory(t interface { - mock.TestingT - Cleanup(func()) -}) *MockSignerFactory { - mock := &MockSignerFactory{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/resources/signature/signature_test.go b/pkg/registry/apis/provisioning/resources/signature/signature_test.go deleted file mode 100644 index b339865ca5f..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/signature_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package signature - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" -) - -func TestSignerFactory_New(t *testing.T) { - tests := []struct { - name string - opts SignOptions - setupMocks func(t *testing.T, clients *resources.MockClientFactory) - expectedType interface{} - expectedError string - }{ - { - name: "should return grafana signer when history is false", - opts: SignOptions{ - History: false, - }, - setupMocks: func(t *testing.T, clients *resources.MockClientFactory) { - // No mocks needed as we shouldn't call any clients - }, - expectedType: &grafanaSigner{}, - }, - { - name: "should return load users once signer when history is true", - opts: SignOptions{ - History: true, - Namespace: "test-ns", - }, - setupMocks: func(t *testing.T, clients *resources.MockClientFactory) { - mockResourceClients := resources.NewMockResourceClients(t) - clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil) - mockResourceClients.On("User", mock.Anything).Return(nil, nil) - }, - expectedType: &loadUsersOnceSigner{}, - }, - { - name: "should return error when clients factory fails", - opts: SignOptions{ - History: true, - Namespace: "test-ns", - }, - setupMocks: func(t *testing.T, clients *resources.MockClientFactory) { - clients.On("Clients", context.Background(), "test-ns").Return(nil, fmt.Errorf("clients error")) - }, - expectedError: "get clients: clients error", - }, - { - name: "should return error when user client fails", - opts: SignOptions{ - History: true, - Namespace: "test-ns", - }, - setupMocks: func(t *testing.T, clients *resources.MockClientFactory) { - mockResourceClients := resources.NewMockResourceClients(t) - clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil) - mockResourceClients.On("User", mock.Anything).Return(nil, fmt.Errorf("user client error")) - }, - expectedError: "get user client: user client error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClients := resources.NewMockClientFactory(t) - tt.setupMocks(t, mockClients) - - factory := NewSignerFactory(mockClients) - signer, err := factory.New(context.Background(), tt.opts) - - if tt.expectedError != "" { - require.Error(t, err) - require.EqualError(t, err, tt.expectedError) - require.Nil(t, signer) - } else { - require.NoError(t, err) - require.NotNil(t, signer) - require.IsType(t, tt.expectedType, signer, "signer should be of expected type") - } - - mockClients.AssertExpectations(t) - }) - } -} diff --git a/pkg/registry/apis/provisioning/resources/signature/signer_mock.go b/pkg/registry/apis/provisioning/resources/signature/signer_mock.go deleted file mode 100644 index 46cd71dab55..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/signer_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. - -package signature - -import ( - context "context" - - utils "github.com/grafana/grafana/pkg/apimachinery/utils" - mock "github.com/stretchr/testify/mock" -) - -// MockSigner is an autogenerated mock type for the Signer type -type MockSigner struct { - mock.Mock -} - -type MockSigner_Expecter struct { - mock *mock.Mock -} - -func (_m *MockSigner) EXPECT() *MockSigner_Expecter { - return &MockSigner_Expecter{mock: &_m.Mock} -} - -// Sign provides a mock function with given fields: ctx, item -func (_m *MockSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) { - ret := _m.Called(ctx, item) - - if len(ret) == 0 { - panic("no return value specified for Sign") - } - - var r0 context.Context - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)); ok { - return rf(ctx, item) - } - if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) context.Context); ok { - r0 = rf(ctx, item) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(context.Context) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, utils.GrafanaMetaAccessor) error); ok { - r1 = rf(ctx, item) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockSigner_Sign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sign' -type MockSigner_Sign_Call struct { - *mock.Call -} - -// Sign is a helper method to define mock.On call -// - ctx context.Context -// - item utils.GrafanaMetaAccessor -func (_e *MockSigner_Expecter) Sign(ctx interface{}, item interface{}) *MockSigner_Sign_Call { - return &MockSigner_Sign_Call{Call: _e.mock.On("Sign", ctx, item)} -} - -func (_c *MockSigner_Sign_Call) Run(run func(ctx context.Context, item utils.GrafanaMetaAccessor)) *MockSigner_Sign_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(utils.GrafanaMetaAccessor)) - }) - return _c -} - -func (_c *MockSigner_Sign_Call) Return(_a0 context.Context, _a1 error) *MockSigner_Sign_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockSigner_Sign_Call) RunAndReturn(run func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)) *MockSigner_Sign_Call { - _c.Call.Return(run) - return _c -} - -// NewMockSigner creates a new instance of MockSigner. 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 NewMockSigner(t interface { - mock.TestingT - Cleanup(func()) -}) *MockSigner { - mock := &MockSigner{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/provisioning/resources/signature/users.go b/pkg/registry/apis/provisioning/resources/signature/users.go deleted file mode 100644 index 1cf4171ed63..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/users.go +++ /dev/null @@ -1,115 +0,0 @@ -package signature - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" - - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" -) - -const maxUsers = 10000 - -type loadUsersOnceSigner struct { - signatures map[string]repository.CommitSignature - client dynamic.ResourceInterface - once sync.Once - onceErr error -} - -// NewLoadUsersOnceSigner returns a Signer that loads the signatures from users -// it will only load the signatures once and cache them -// if the user is not found, it will use the grafana user as the author -func NewLoadUsersOnceSigner(client dynamic.ResourceInterface) Signer { - return &loadUsersOnceSigner{ - client: client, - once: sync.Once{}, - signatures: map[string]repository.CommitSignature{}, - } -} - -func (s *loadUsersOnceSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) { - if s.onceErr != nil { - return ctx, fmt.Errorf("load signatures: %w", s.onceErr) - } - - var err error - s.once.Do(func() { - s.signatures, err = s.load(ctx, s.client) - s.onceErr = err - }) - if err != nil { - return ctx, fmt.Errorf("load signatures: %w", err) - } - - id := item.GetUpdatedBy() - if id == "" { - id = item.GetCreatedBy() - } - if id == "" { - id = "grafana" - } - - sig := s.signatures[id] // lookup - if sig.Name == "" && sig.Email == "" { - sig.Name = id - } - t, err := item.GetUpdatedTimestamp() - if err == nil && t != nil { - sig.When = *t - } else { - sig.When = item.GetCreationTimestamp().Time - } - - return repository.WithAuthorSignature(ctx, sig), nil -} - -func (s *loadUsersOnceSigner) load(ctx context.Context, client dynamic.ResourceInterface) (map[string]repository.CommitSignature, error) { - userInfo := make(map[string]repository.CommitSignature) - var count int - err := resources.ForEach(ctx, client, func(item *unstructured.Unstructured) error { - count++ - if count > maxUsers { - return errors.New("too many users") - } - - sig := repository.CommitSignature{} - // FIXME: should we improve logging here? - var ( - ok bool - err error - ) - - sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login") - if !ok || err != nil { - return nil - } - sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email") - if !ok || err != nil { - return nil - } - - if sig.Name == sig.Email { - if sig.Name == "" { - sig.Name = item.GetName() - } else if strings.Contains(sig.Email, "@") { - sig.Email = "" // don't use the same value for name+email - } - } - - userInfo["user:"+item.GetName()] = sig - return nil - }) - if err != nil { - return nil, err - } - - return userInfo, nil -} diff --git a/pkg/registry/apis/provisioning/resources/signature/users_test.go b/pkg/registry/apis/provisioning/resources/signature/users_test.go deleted file mode 100644 index ccd63014a76..00000000000 --- a/pkg/registry/apis/provisioning/resources/signature/users_test.go +++ /dev/null @@ -1,357 +0,0 @@ -package signature - -import ( - "context" - "errors" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" - - "github.com/grafana/grafana/apps/provisioning/pkg/repository" - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -// mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface -type mockDynamicInterface struct { - dynamic.ResourceInterface - items []unstructured.Unstructured - err error -} - -func (m *mockDynamicInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { - if m.err != nil { - return nil, m.err - } - return &unstructured.UnstructuredList{ - Items: m.items, - }, nil -} - -type mockGrafanaMetaAccessor struct { - utils.GrafanaMetaAccessor - createdBy string - updatedBy string - creationTimestamp time.Time - updatedTimestamp *time.Time - updatedTimestampErr error -} - -func (m *mockGrafanaMetaAccessor) GetCreatedBy() string { - return m.createdBy -} - -func (m *mockGrafanaMetaAccessor) GetUpdatedBy() string { - return m.updatedBy -} - -func (m *mockGrafanaMetaAccessor) GetCreationTimestamp() metav1.Time { - return metav1.Time{Time: m.creationTimestamp} -} - -func (m *mockGrafanaMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) { - if m.updatedTimestampErr != nil { - return nil, m.updatedTimestampErr - } - return m.updatedTimestamp, nil -} - -func TestLoadUsersOnceSigner_Sign(t *testing.T) { - baseTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - updateTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) - - tests := []struct { - name string - items []unstructured.Unstructured - meta *mockGrafanaMetaAccessor - clientErr error - expectedSig repository.CommitSignature - expectedError string - }{ - { - name: "should sign with user info when user exists", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "johndoe", - "email": "john@example.com", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - updatedTimestamp: &updateTime, - }, - expectedSig: repository.CommitSignature{ - Name: "johndoe", - Email: "john@example.com", - When: updateTime, - }, - }, - { - name: "should fallback to created by when updated by is empty", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "johndoe", - "email": "john@example.com", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - createdBy: "user:user1", - creationTimestamp: baseTime, - }, - expectedSig: repository.CommitSignature{ - Name: "johndoe", - Email: "john@example.com", - When: baseTime, - }, - }, - { - name: "should use grafana when no user info available", - meta: &mockGrafanaMetaAccessor{ - creationTimestamp: baseTime, - }, - expectedSig: repository.CommitSignature{ - Name: "grafana", - When: baseTime, - }, - }, - { - name: "should handle user with same login and email", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "john@example.com", - "email": "john@example.com", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - updatedTimestamp: &updateTime, - }, - expectedSig: repository.CommitSignature{ - Name: "john@example.com", - Email: "", - When: updateTime, - }, - }, - { - name: "should handle empty login and email", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "", - "email": "", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - updatedTimestamp: &updateTime, - }, - expectedSig: repository.CommitSignature{ - Name: "user1", - Email: "", - When: updateTime, - }, - }, - { - name: "should handle empty email", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "johndoe", - "email": "", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - updatedTimestamp: &updateTime, - }, - expectedSig: repository.CommitSignature{ - Name: "johndoe", - Email: "", - When: updateTime, - }, - }, - { - name: "should fail when too many users", - items: func() []unstructured.Unstructured { - items := make([]unstructured.Unstructured, maxUsers+1) - for i := 0; i < maxUsers+1; i++ { - items[i] = unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "johndoe", - "email": "john@example.com", - }, - }, - } - } - return items - }(), - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - }, - expectedError: "load signatures: too many users", - }, - { - name: "should handle missing user fields gracefully", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - // missing login and email - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - }, - expectedSig: repository.CommitSignature{ - Name: "user:user1", - When: baseTime, - }, - }, - { - name: "should use creation timestamp when update timestamp has error", - items: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "user1", - }, - "spec": map[string]interface{}{ - "login": "johndoe", - "email": "john@example.com", - }, - }, - }, - }, - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - updatedTimestampErr: errors.New("update timestamp error"), - }, - expectedSig: repository.CommitSignature{ - Name: "johndoe", - Email: "john@example.com", - When: baseTime, - }, - }, - { - name: "should fail when listing users fails", - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - }, - clientErr: fmt.Errorf("failed to list users"), - expectedError: "load signatures: error executing list: failed to list users", - }, - { - name: "should handle empty user list", - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - }, - items: []unstructured.Unstructured{}, - expectedSig: repository.CommitSignature{ - Name: "user:user1", - When: baseTime, - }, - }, - { - name: "should handle multiple calls with error", - meta: &mockGrafanaMetaAccessor{ - updatedBy: "user:user1", - creationTimestamp: baseTime, - }, - clientErr: fmt.Errorf("failed to list users"), - expectedError: "load signatures: error executing list: failed to list users", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &mockDynamicInterface{ - items: tt.items, - err: tt.clientErr, - } - - signer := NewLoadUsersOnceSigner(client) - ctx := context.Background() - - signedCtx, err := signer.Sign(ctx, tt.meta) - - if tt.expectedError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tt.expectedError) - - // Test that subsequent calls also fail with the same error - _, err2 := signer.Sign(ctx, tt.meta) - require.Error(t, err2) - require.Contains(t, err2.Error(), tt.expectedError) - return - } - - require.NoError(t, err) - sig := repository.GetAuthorSignature(signedCtx) - require.NotNil(t, sig) - require.Equal(t, tt.expectedSig.Name, sig.Name) - require.Equal(t, tt.expectedSig.Email, sig.Email) - require.Equal(t, tt.expectedSig.When, sig.When) - - // Test that subsequent calls use cached data - signedCtx2, err := signer.Sign(ctx, tt.meta) - require.NoError(t, err) - sig2 := repository.GetAuthorSignature(signedCtx2) - require.Equal(t, sig, sig2) - }) - } -} diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index 494d47d16e7..c818a76812b 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -14,7 +14,6 @@ import ( authlib "github.com/grafana/authlib/types" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/util/errhttp" ) @@ -156,16 +155,9 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { return } - legacyStorage := false - if b.storageStatus != nil { - legacyStorage = dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus) - } - settings := provisioning.RepositoryViewList{ - Items: make([]provisioning.RepositoryView, len(all)), - AllowedTargets: b.allowedTargets, - // FIXME: this shouldn't be here in provisioning but at the dual writer or something about the storage - LegacyStorage: legacyStorage, + Items: make([]provisioning.RepositoryView, len(all)), + AllowedTargets: b.allowedTargets, AvailableRepositoryTypes: b.repoFactory.Types(), AllowImageRendering: b.allowImageRendering, } diff --git a/pkg/registry/apps/alerting/notifications/register.go b/pkg/registry/apps/alerting/notifications/register.go index fbf75d44bb5..c96fa1759b7 100644 --- a/pkg/registry/apps/alerting/notifications/register.go +++ b/pkg/registry/apps/alerting/notifications/register.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/timeinterval" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert" ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -97,7 +98,12 @@ func (a AlertingNotificationsAppInstaller) GetLegacyStorage(gvr schema.GroupVers } else if gvr == timeinterval.ResourceInfo.GroupVersionResource() { return timeinterval.NewStorage(api.MuteTimings, namespacer) } else if gvr == templategroup.ResourceInfo.GroupVersionResource() { - return templategroup.NewStorage(api.Templates, namespacer) + srv := api.Templates + //nolint:staticcheck // not yet migrated to OpenFeature + if a.ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) { + srv = srv.WithIncludeImported() + } + return templategroup.NewStorage(srv, namespacer) } else if gvr == routingtree.ResourceInfo.GroupVersionResource() { return routingtree.NewStorage(api.Policies, namespacer) } diff --git a/pkg/registry/apps/alerting/notifications/templategroup/conversions.go b/pkg/registry/apps/alerting/notifications/templategroup/conversions.go index 95cd2399816..f009a0d3329 100644 --- a/pkg/registry/apps/alerting/notifications/templategroup/conversions.go +++ b/pkg/registry/apps/alerting/notifications/templategroup/conversions.go @@ -1,6 +1,7 @@ package templategroup import ( + "github.com/grafana/alerting/definition" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" @@ -36,6 +37,7 @@ func convertToK8sResource(orgID int64, template definitions.NotificationTemplate Spec: model.TemplateGroupSpec{ Title: template.Name, Content: template.Template, + Kind: model.TemplateGroupTemplateKind(template.Kind), }, } result.SetProvenanceStatus(string(template.Provenance)) @@ -50,5 +52,6 @@ func convertToDomainModel(template *model.TemplateGroup) definitions.Notificatio Template: template.Spec.Content, ResourceVersion: template.ResourceVersion, Provenance: definitions.Provenance(ngmodels.ProvenanceNone), + Kind: definition.TemplateKind(template.Spec.Kind), } } diff --git a/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go b/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go index caf2558da96..2b1ff3e6638 100644 --- a/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/grafana/alerting/definition" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -211,6 +212,7 @@ func (s *legacyStorage) defaultTemplate() (definitions.NotificationTemplate, err UID: defaultTemplate.Name, Provenance: definitions.Provenance("system"), Template: defaultTemplate.Template, + Kind: definition.GrafanaTemplateKind, } return dto, nil diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index 9113d927a29..68082f59713 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -2,16 +2,19 @@ package plugins import ( "fmt" - "os" authlib "github.com/grafana/authlib/types" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app" "github.com/grafana/grafana/apps/plugins/pkg/app/meta" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) var ( @@ -20,23 +23,26 @@ var ( ) type AppInstaller struct { + metaManager *meta.ProviderManager + cfgProvider configprovider.ConfigProvider + restConfigProvider apiserver.RestConfigProvider + *pluginsapp.PluginAppInstaller } -func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClient authlib.AccessClient) (*AppInstaller, error) { +func ProvideAppInstaller( + cfgProvider configprovider.ConfigProvider, + restConfigProvider apiserver.RestConfigProvider, + pluginStore pluginstore.Store, + pluginAssetsService *pluginassets.Service, + accessControlService accesscontrol.Service, accessClient authlib.AccessClient, +) (*AppInstaller, error) { if err := registerAccessControlRoles(accessControlService); err != nil { return nil, fmt.Errorf("registering access control roles: %w", err) } - grafanaComAPIURL := os.Getenv("GRAFANA_COM_API_URL") - if grafanaComAPIURL == "" { - grafanaComAPIURL = "https://grafana.com/api/plugins" - } - - coreProvider := meta.NewCoreProvider() - cloudProvider := meta.NewCatalogProvider(grafanaComAPIURL) - metaProviderManager := meta.NewProviderManager(coreProvider, cloudProvider) - + localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService) + metaProviderManager := meta.NewProviderManager(localProvider) authorizer := grafanaauthorizer.NewResourceAuthorizer(accessClient) i, err := pluginsapp.ProvideAppInstaller(authorizer, metaProviderManager) if err != nil { @@ -44,6 +50,9 @@ func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClien } return &AppInstaller{ + metaManager: metaProviderManager, + cfgProvider: cfgProvider, + restConfigProvider: restConfigProvider, PluginAppInstaller: i, }, nil } diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index a91fa138309..a2cef7b2d55 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -2,7 +2,6 @@ package appregistry import ( "github.com/google/wire" - "github.com/grafana/grafana/pkg/registry/apps/quotas" "github.com/grafana/grafana/pkg/registry/apps/alerting/historian" "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications" @@ -14,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" + "github.com/grafana/grafana/pkg/registry/apps/quotas" "github.com/grafana/grafana/pkg/registry/apps/shorturl" ) diff --git a/pkg/semconv/Makefile b/pkg/semconv/Makefile index 6544e6ad79e..13058b4d235 100644 --- a/pkg/semconv/Makefile +++ b/pkg/semconv/Makefile @@ -3,7 +3,7 @@ all: go markdown .PHONY: go go: - @docker run --rm -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \ + @docker run --rm --platform linux/amd64 -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \ otel/semconvgen --yaml-root "/tmp/grafana/model/" \ code --template "/tmp/grafana/templates/template.j2" \ --output "/tmp/grafana/output/attributes.go" @@ -11,7 +11,7 @@ go: .PHONY: markdown markdown: - @docker run --rm -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \ + @docker run --rm --platform linux/amd64 -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \ otel/semconvgen --yaml-root "/tmp/grafana/model/" \ markdown --markdown-root "/tmp/grafana/output/" @npx --yes -- markdown-toc --bullets "-" --no-first-h1 --no-stripHeadingTags -i README.md || exit 1 diff --git a/pkg/semconv/README.md b/pkg/semconv/README.md index 585034221e0..b80a64ad1c9 100644 --- a/pkg/semconv/README.md +++ b/pkg/semconv/README.md @@ -55,6 +55,7 @@ For more information: | Attribute | Type | Description | Examples | [Requirement Level](https://opentelemetry.io/docs/specs/semconv/general/attribute-requirement-level/) | Stability | |---|---|---|---|---|---| | `grafana.plugin.id` | string | The plugin ID. | `prometheus`; `loki`; `grafana-github-datasource` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) | +| `grafana.plugin.source.class` | string | The plugin source class. | `core` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) | | `grafana.plugin.type` | string | The plugin type. | `datasource` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) | diff --git a/pkg/semconv/attributes.go b/pkg/semconv/attributes.go index c759ab2e9c0..dbc19ead8e5 100644 --- a/pkg/semconv/attributes.go +++ b/pkg/semconv/attributes.go @@ -140,6 +140,27 @@ func GrafanaPluginId(val string) attribute.KeyValue { return grafanaPluginIdKey.String(val) } +// Describes Grafana plugin source attributes. +const ( + // GrafanaPluginSourceClassKey is the attribute Key conforming to the + // "grafana.plugin.source.class" semantic conventions. It represents the + // plugin source class. + // + // Type: Enum + // RequirementLevel: Optional + // Stability: stable + grafanaPluginSourceClassKey = attribute.Key("grafana.plugin.source.class") +) + +var ( + // Core Plugin + GrafanaPluginSourceClassCore = grafanaPluginSourceClassKey.String("core") + // External Plugin + GrafanaPluginSourceClassExternal = grafanaPluginSourceClassKey.String("external") + // Unknown Plugin Source + GrafanaPluginSourceClassUnknown = grafanaPluginSourceClassKey.String("unknown") +) + // Describes Grafana service attributes. const ( // GrafanaServiceNameKey is the attribute Key conforming to the diff --git a/pkg/semconv/model/registry/plugin.yml b/pkg/semconv/model/registry/plugin.yml index 431794bcd34..1dc2123b2a3 100644 --- a/pkg/semconv/model/registry/plugin.yml +++ b/pkg/semconv/model/registry/plugin.yml @@ -34,3 +34,26 @@ groups: brief: The plugin type. examples: datasource stability: stable + + - id: registry.grafana.plugin.source + type: attribute_group + display_name: Grafana Plugin Source Attributes + brief: "Describes Grafana plugin source attributes." + attributes: + - id: grafana.plugin.source.class + type: + members: + - id: core + value: "core" + brief: 'Core Plugin' + stability: stable + - id: external + value: "external" + brief: 'External Plugin' + stability: stable + - id: unknown + value: "unknown" + brief: 'Unknown Plugin Source' + stability: stable + brief: The plugin source class. + stability: stable diff --git a/pkg/semconv/model/trace/plugin.yaml b/pkg/semconv/model/trace/plugin.yaml index f25e894efbc..b25e1428454 100644 --- a/pkg/semconv/model/trace/plugin.yaml +++ b/pkg/semconv/model/trace/plugin.yaml @@ -5,4 +5,5 @@ groups: stability: stable attributes: - ref: grafana.plugin.id - - ref: grafana.plugin.type \ No newline at end of file + - ref: grafana.plugin.type + - ref: grafana.plugin.source.class \ No newline at end of file diff --git a/pkg/semconv/plugins.go b/pkg/semconv/plugins.go new file mode 100644 index 00000000000..b9e2b90fd08 --- /dev/null +++ b/pkg/semconv/plugins.go @@ -0,0 +1,19 @@ +package semconv + +import ( + "fmt" + + "go.opentelemetry.io/otel/attribute" +) + +// PluginSourceClass converts a plugin source class to the corresponding semantic convention attribute. +func PluginSourceClass(class fmt.Stringer) attribute.KeyValue { + switch class.String() { + case "core": + return GrafanaPluginSourceClassCore + case "external": + return GrafanaPluginSourceClassExternal + default: + return GrafanaPluginSourceClassUnknown + } +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 5ed04adf12b..478ef749f11 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -786,7 +786,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) if err != nil { return nil, err } @@ -1447,7 +1447,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) if err != nil { return nil, err } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2044c9c40f2..b0beaa05482 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -600,6 +600,13 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, }, + { + Name: "drilldownRecommendations", + Description: "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, { Name: "perPanelNonApplicableDrilldowns", Description: "Enables viewing non-applicable drilldowns on a panel level", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0f681344984..46581205ae4 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -83,6 +83,7 @@ dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true +drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 31b501a8feb..4cc98de091b 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1181,6 +1181,19 @@ "codeowner": "@grafana/grafana-datasources-core-services" } }, + { + "metadata": { + "name": "drilldownRecommendations", + "resourceVersion": "1764855550769", + "creationTimestamp": "2025-12-04T13:39:10Z" + }, + "spec": { + "description": "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "elasticsearchCrossClusterSearch", diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go index 0ff7a25b0e9..3a47c41fffb 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -439,7 +439,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { ID: fooFolder.ID, // nolint:staticcheck UID: fooFolder.UID, }, - }, nil).Once() + }, nil).Twice() // Called twice due to total count call id := int64(123) emptyString := "" query := &folder.GetFolderQuery{ @@ -455,7 +455,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { }) t.Run("When get folder by non existing ID should return not found error", func(t *testing.T) { - dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once() + dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Twice() // Called twice due to total count call id := int64(111111) query := &folder.GetFolderQuery{ ID: &id, @@ -475,7 +475,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { ID: fooFolder.ID, // nolint:staticcheck UID: fooFolder.UID, }, - }, nil).Once() + }, nil).Twice() // Called twice due to total count call title := "foo" query := &folder.GetFolderQuery{ Title: &title, @@ -489,7 +489,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { }) t.Run("When get folder by non existing Title should return not found error", func(t *testing.T) { - dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once() + dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Twice() // Called twice due to total count call title := "does not exists" query := &folder.GetFolderQuery{ Title: &title, diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index c3fce7cb1b4..c0bc3685638 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -5,7 +5,8 @@ import ( "regexp" "strings" - "github.com/grafana/alerting/templates" + "github.com/grafana/alerting/definition" + "github.com/grafana/alerting/notify" "go.yaml.in/yaml/v3" ) @@ -31,11 +32,18 @@ func (t *NotificationTemplate) Validate() error { content = fmt.Sprintf("{{ define \"%s\" }}\n%s\n{{ end }}", t.Name, content) } t.Template = content - def := templates.TemplateDefinition{ - Name: t.Name, - Template: t.Template, - Kind: templates.GrafanaKind, + if t.Kind == "" { + t.Kind = definition.GrafanaTemplateKind } + postable := definition.PostableApiTemplate{ + Name: t.Name, + Content: t.Template, + Kind: t.Kind, + } + if err := postable.Validate(); err != nil { + return err + } + def := notify.PostableAPITemplateToTemplateDefinition(postable) return def.Validate() } diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go index 74f186003d7..2b5139a4960 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation_test.go @@ -462,6 +462,16 @@ func TestValidateNotificationTemplates(t *testing.T) { }, expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`, }, + { + name: "unknown template kind", + template: NotificationTemplate{ + Name: "Alert Instance Template", + Template: `{{ define "Same name as definition" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}`, + Provenance: "test", + Kind: "unknown", + }, + expError: errors.New("unknown template kind: unknown"), + }, } for _, tt := range tc { diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go index 1633a903822..3c64573a812 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_templates.go @@ -1,5 +1,7 @@ package definitions +import "github.com/grafana/alerting/definition" + // swagger:route GET /v1/provisioning/templates provisioning stable RouteGetTemplates // // Get all notification template groups. @@ -55,11 +57,12 @@ type RouteDeleteTemplateParam struct { // swagger:model type NotificationTemplate struct { - UID string `json:"-" yaml:"-"` - Name string `json:"name"` - Template string `json:"template"` - Provenance Provenance `json:"provenance,omitempty"` - ResourceVersion string `json:"version,omitempty"` + UID string `json:"-" yaml:"-"` + Name string `json:"name"` + Template string `json:"template"` + Provenance Provenance `json:"provenance,omitempty"` + ResourceVersion string `json:"version,omitempty"` + Kind definition.TemplateKind `json:"-" yaml:"-"` } // swagger:model diff --git a/pkg/services/ngalert/provisioning/errors.go b/pkg/services/ngalert/provisioning/errors.go index 407f45efed6..df3683751b3 100644 --- a/pkg/services/ngalert/provisioning/errors.go +++ b/pkg/services/ngalert/provisioning/errors.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/apimachinery/errutil" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -25,6 +26,10 @@ var ( ErrTemplateNotFound = errutil.NotFound("alerting.notifications.templates.notFound") ErrTemplateInvalid = errutil.BadRequest("alerting.notifications.templates.invalidFormat").MustTemplate("Invalid format of the submitted template", errutil.WithPublic("Template is in invalid format. Correct the payload and try again.")) ErrTemplateExists = errutil.BadRequest("alerting.notifications.templates.nameExists", errutil.WithPublicMessage("Template file with this name already exists. Use a different name or update existing one.")) + ErrTemplateOrigin = errutil.BadRequest("alerting.notifications.templates.originInvalid").MustTemplate( + "Template '{{ .Public.Name }}' cannot be {{ .Public.Action }}d because it belongs to an imported configuration.", + errutil.WithPublic("Template '{{ .Public.Name }}' cannot be {{ .Public.Action }}d because it belongs to an imported configuration. Finish the import of the configuration first."), + ) ErrContactPointReferenced = errutil.Conflict("alerting.notifications.contact-points.referenced", errutil.WithPublicMessage("Contact point is currently referenced by a notification policy.")) ErrContactPointUsedInRule = errutil.Conflict("alerting.notifications.contact-points.used-by-rule", errutil.WithPublicMessage("Contact point is currently used in the notification settings of one or many alert rules.")) @@ -129,3 +134,7 @@ func MakeErrContactPointUidExists(uid, name string) error { }, }) } + +func makeErrTemplateOrigin(t definitions.NotificationTemplate, action string) error { + return ErrTemplateOrigin.Build(errutil.TemplateData{Public: map[string]interface{}{"Action": action, "Name": t.Name}}) +} diff --git a/pkg/services/ngalert/provisioning/templates.go b/pkg/services/ngalert/provisioning/templates.go index 9d4db1ed140..65cecfb506b 100644 --- a/pkg/services/ngalert/provisioning/templates.go +++ b/pkg/services/ngalert/provisioning/templates.go @@ -10,6 +10,8 @@ import ( "sort" "unsafe" + "github.com/grafana/alerting/definition" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -23,6 +25,7 @@ type TemplateService struct { xact TransactionManager log log.Logger validator validation.ProvenanceStatusTransitionValidator + includeImported bool } func NewTemplateService(config alertmanagerConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *TemplateService { @@ -32,6 +35,18 @@ func NewTemplateService(config alertmanagerConfigStore, prov ProvisioningStore, xact: xact, validator: validation.ValidateProvenanceRelaxed, log: log, + includeImported: false, + } +} + +func (t *TemplateService) WithIncludeImported() *TemplateService { + return &TemplateService{ + configStore: t.configStore, + provenanceStore: t.provenanceStore, + xact: t.xact, + validator: t.validator, + log: t.log, + includeImported: true, } } @@ -41,35 +56,38 @@ func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) ([]defi return nil, err } - if len(revision.Config.TemplateFiles) == 0 { - return nil, nil - } + var templates []definitions.NotificationTemplate - provenances, err := t.provenanceStore.GetProvenances(ctx, orgID, (&definitions.NotificationTemplate{}).ResourceType()) - if err != nil { - return nil, err - } - - templates := make([]definitions.NotificationTemplate, 0, len(revision.Config.TemplateFiles)) - names := slices.Collect(maps.Keys(revision.Config.TemplateFiles)) - sort.Strings(names) - for _, name := range names { - content := revision.Config.TemplateFiles[name] - tmpl := definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(name), - Name: name, - Template: content, - ResourceVersion: calculateTemplateFingerprint(content), + if len(revision.Config.TemplateFiles) > 0 { + provenances, err := t.provenanceStore.GetProvenances(ctx, orgID, (&definitions.NotificationTemplate{}).ResourceType()) + if err != nil { + return nil, err } - provenance, ok := provenances[tmpl.ResourceID()] - if !ok { - provenance = models.ProvenanceNone + templates = make([]definitions.NotificationTemplate, 0, len(revision.Config.TemplateFiles)) + names := slices.Collect(maps.Keys(revision.Config.TemplateFiles)) + sort.Strings(names) + for _, name := range names { + content := revision.Config.TemplateFiles[name] + provenance, ok := provenances[(&definitions.NotificationTemplate{Name: name}).ResourceID()] + if !ok { + provenance = models.ProvenanceNone + } + templates = append(templates, newNotificationTemplate(name, content, provenance, definition.GrafanaTemplateKind)) } - tmpl.Provenance = definitions.Provenance(provenance) - templates = append(templates, tmpl) } - return templates, nil + var importedTemplates []definitions.NotificationTemplate + if t.includeImported && len(revision.Config.ExtraConfigs) > 0 && len(revision.Config.ExtraConfigs[0].TemplateFiles) > 0 { + imported := revision.Config.ExtraConfigs[0].TemplateFiles + importedTemplates = make([]definitions.NotificationTemplate, 0, len(imported)) + names := slices.Collect(maps.Keys(imported)) + sort.Strings(names) + for _, name := range names { + content := imported[name] + templates = append(templates, newNotificationTemplate(name, content, models.ProvenanceConvertedPrometheus, definition.MimirTemplateKind)) + } + } + return append(templates, importedTemplates...), nil } func (t *TemplateService) GetTemplate(ctx context.Context, orgID int64, nameOrUid string) (definitions.NotificationTemplate, error) { @@ -77,29 +95,21 @@ func (t *TemplateService) GetTemplate(ctx context.Context, orgID int64, nameOrUi if err != nil { return definitions.NotificationTemplate{}, err } - - existingName := nameOrUid - existingContent, ok := revision.Config.TemplateFiles[nameOrUid] - if !ok { - existingName, existingContent, ok = getTemplateByUid(revision.Config.TemplateFiles, nameOrUid) - } - if !ok { - return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("") - } - - tmpl := definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(existingName), - Name: existingName, - Template: existingContent, - ResourceVersion: calculateTemplateFingerprint(existingContent), - } - - provenance, err := t.provenanceStore.GetProvenance(ctx, &tmpl, orgID) + result, found, err := t.getTemplateByName(ctx, revision, orgID, nameOrUid) if err != nil { return definitions.NotificationTemplate{}, err } - tmpl.Provenance = definitions.Provenance(provenance) - return tmpl, nil + if found { + return result, nil + } + result, found, err = t.getTemplateByUID(ctx, revision, orgID, nameOrUid) + if err != nil { + return definitions.NotificationTemplate{}, err + } + if found { + return result, nil + } + return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("") } func (t *TemplateService) UpsertTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) { @@ -135,6 +145,10 @@ func (t *TemplateService) CreateTemplate(ctx context.Context, orgID int64, tmpl if err != nil { return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(err) } + if tmpl.Kind == definition.MimirTemplateKind { + return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("templates of kind 'Mimir' cannot be created")) + } + revision, err := t.configStore.Get(ctx, orgID) if err != nil { return definitions.NotificationTemplate{}, err @@ -143,6 +157,10 @@ func (t *TemplateService) CreateTemplate(ctx context.Context, orgID int64, tmpl } func (t *TemplateService) createTemplate(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) { + if tmpl.Kind == definition.MimirTemplateKind { + return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("templates of kind 'Mimir' cannot be created")) + } + if revision.Config.TemplateFiles == nil { revision.Config.TemplateFiles = map[string]string{} } @@ -164,13 +182,7 @@ func (t *TemplateService) createTemplate(ctx context.Context, revision *legacy_s return definitions.NotificationTemplate{}, err } - return definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, nil + return newNotificationTemplate(tmpl.Name, tmpl.Template, models.Provenance(tmpl.Provenance), tmpl.Kind), nil } func (t *TemplateService) UpdateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) { @@ -192,37 +204,39 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s } var found bool - var existingName, existingContent string + var err error + var existing definitions.NotificationTemplate // if UID is specified, look by UID. if tmpl.UID != "" { - existingName, existingContent, found = getTemplateByUid(revision.Config.TemplateFiles, tmpl.UID) - // do not fall back to name because we address by UID, and resource can be deleted\renamed + existing, found, err = t.getTemplateByUID(ctx, revision, orgID, tmpl.UID) } else { - existingName = tmpl.Name - existingContent, found = revision.Config.TemplateFiles[existingName] + existing, found, err = t.getTemplateByName(ctx, revision, orgID, tmpl.Name) + } + if err != nil { + return definitions.NotificationTemplate{}, err } if !found { return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("") } - if existingName != tmpl.Name { // if template is renamed, check if this name is already taken + if existing.Name != tmpl.Name { // if template is renamed, check if this name is already taken _, ok := revision.Config.TemplateFiles[tmpl.Name] if ok { // return error if template is being renamed to one that already exists return definitions.NotificationTemplate{}, ErrTemplateExists.Errorf("") } } - - // check that provenance is not changed in an invalid way - storedProvenance, err := t.provenanceStore.GetProvenance(ctx, &tmpl, orgID) - if err != nil { - return definitions.NotificationTemplate{}, err + if existing.Kind != tmpl.Kind { + return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("cannot change template kind")) } - if err := t.validator(storedProvenance, models.Provenance(tmpl.Provenance)); err != nil { + if existing.Provenance == definitions.Provenance(models.ProvenanceConvertedPrometheus) { + return definitions.NotificationTemplate{}, makeErrTemplateOrigin(existing, "update") + } + if err := t.validator(models.Provenance(existing.Provenance), models.Provenance(tmpl.Provenance)); err != nil { return definitions.NotificationTemplate{}, err } - err = t.checkOptimisticConcurrency(tmpl.Name, existingContent, models.Provenance(tmpl.Provenance), tmpl.ResourceVersion, "update") + err = t.checkOptimisticConcurrency(existing.Name, existing.Template, models.Provenance(tmpl.Provenance), tmpl.ResourceVersion, "update") if err != nil { return definitions.NotificationTemplate{}, err } @@ -230,9 +244,9 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s revision.Config.TemplateFiles[tmpl.Name] = tmpl.Template err = t.xact.InTransaction(ctx, func(ctx context.Context) error { - if existingName != tmpl.Name { // if template by was found by UID and it's name is different, then this is the rename operation. Delete old resources. - delete(revision.Config.TemplateFiles, existingName) - err := t.provenanceStore.DeleteProvenance(ctx, &definitions.NotificationTemplate{Name: existingName}, orgID) + if existing.Name != tmpl.Name { // if template by was found by UID and it's name is different, then this is the rename operation. Delete old resources. + delete(revision.Config.TemplateFiles, existing.Name) + err := t.provenanceStore.DeleteProvenance(ctx, &existing, orgID) if err != nil { return err } @@ -247,13 +261,8 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s return definitions.NotificationTemplate{}, err } - return definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), // if name was changed, this UID will not match the incoming one - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, nil + // if name was changed, this UID needs to be recalculated + return newNotificationTemplate(tmpl.Name, tmpl.Template, models.Provenance(tmpl.Provenance), tmpl.Kind), nil } func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, nameOrUid string, provenance definitions.Provenance, version string) error { @@ -261,44 +270,39 @@ func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, nameO if err != nil { return err } - - if revision.Config.TemplateFiles == nil { + existing, found, err := t.getTemplateByName(ctx, revision, orgID, nameOrUid) + if err != nil { + return err + } + if !found { + existing, found, err = t.getTemplateByUID(ctx, revision, orgID, nameOrUid) + } + if err != nil { + return err + } + if !found { return nil } - - existingName := nameOrUid - existing, ok := revision.Config.TemplateFiles[nameOrUid] - if !ok { - existingName, existing, ok = getTemplateByUid(revision.Config.TemplateFiles, nameOrUid) - } - if !ok { - return nil + if existing.Provenance == definitions.Provenance(models.ProvenanceConvertedPrometheus) { + return makeErrTemplateOrigin(existing, "delete") } - err = t.checkOptimisticConcurrency(existingName, existing, models.Provenance(provenance), version, "delete") + err = t.checkOptimisticConcurrency(existing.Name, existing.Template, models.Provenance(provenance), version, "delete") if err != nil { return err } - // check that provenance is not changed in an invalid way - storedProvenance, err := t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: existingName}, orgID) - if err != nil { - return err - } - if err = t.validator(storedProvenance, models.Provenance(provenance)); err != nil { + if err = t.validator(models.Provenance(existing.Provenance), models.Provenance(provenance)); err != nil { return err } - delete(revision.Config.TemplateFiles, existingName) + delete(revision.Config.TemplateFiles, existing.Name) return t.xact.InTransaction(ctx, func(ctx context.Context) error { if err := t.configStore.Save(ctx, revision, orgID); err != nil { return err } - tgt := definitions.NotificationTemplate{ - Name: existingName, - } - return t.provenanceStore.DeleteProvenance(ctx, &tgt, orgID) + return t.provenanceStore.DeleteProvenance(ctx, &existing, orgID) }) } @@ -323,11 +327,58 @@ func calculateTemplateFingerprint(t string) string { return fmt.Sprintf("%016x", sum.Sum64()) } -func getTemplateByUid(templates map[string]string, uid string) (string, string, bool) { - for n, tmpl := range templates { - if legacy_storage.NameToUid(n) == uid { - return n, tmpl, true - } +func newNotificationTemplate(name, content string, provenance models.Provenance, kind definition.TemplateKind) definitions.NotificationTemplate { + tmpl := definitions.NotificationTemplate{ + UID: templateUID(kind, name), + Name: name, + Template: content, + Provenance: definitions.Provenance(provenance), + Kind: kind, } - return "", "", false + tmpl.ResourceVersion = calculateTemplateFingerprint(content) + return tmpl +} + +func (t *TemplateService) getTemplateByName(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, name string) (definitions.NotificationTemplate, bool, error) { + existingContent, ok := revision.Config.TemplateFiles[name] + if !ok { + return definitions.NotificationTemplate{}, false, nil + } + provenance, err := t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: name}, orgID) + if err != nil { + return definitions.NotificationTemplate{}, false, err + } + return newNotificationTemplate(name, existingContent, provenance, definition.GrafanaTemplateKind), true, nil +} + +func (t *TemplateService) getTemplateByUID(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, uid string) (definitions.NotificationTemplate, bool, error) { + find := func(templates map[string]string, uid string, kind definition.TemplateKind) (string, string, bool) { + for n, tmpl := range templates { + if templateUID(kind, n) == uid { + return n, tmpl, true + } + } + return "", "", false + } + var provenance models.Provenance + name, content, ok := find(revision.Config.TemplateFiles, uid, definition.GrafanaTemplateKind) + if !ok { + if t.includeImported && len(revision.Config.ExtraConfigs) > 0 { + name, content, ok = find(revision.Config.ExtraConfigs[0].TemplateFiles, uid, definition.MimirTemplateKind) + if ok { + return newNotificationTemplate(name, content, models.ProvenanceConvertedPrometheus, definition.MimirTemplateKind), true, nil + } + } + return definitions.NotificationTemplate{}, false, nil + } + var err error + provenance, err = t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: name}, orgID) + if err != nil { + return definitions.NotificationTemplate{}, false, err + } + return newNotificationTemplate(name, content, provenance, definition.GrafanaTemplateKind), true, nil +} + +func templateUID(kind definition.TemplateKind, name string) string { + return legacy_storage.NameToUid(fmt.Sprintf("%s|%s", string(kind), name)) } diff --git a/pkg/services/ngalert/provisioning/templates_test.go b/pkg/services/ngalert/provisioning/templates_test.go index 5b989dd678d..44ced3ba7f9 100644 --- a/pkg/services/ngalert/provisioning/templates_test.go +++ b/pkg/services/ngalert/provisioning/templates_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + "github.com/grafana/alerting/definition" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -27,6 +28,15 @@ func TestGetTemplates(t *testing.T) { "template2": "test2", "template3": "test3", }, + ExtraConfigs: []definitions.ExtraConfiguration{ + { + Identifier: "1234", + TemplateFiles: map[string]string{ + "template1": "imported-test1", + "template4": "imported-test4", + }, + }, + }, }, } @@ -45,27 +55,24 @@ func TestGetTemplates(t *testing.T) { require.NoError(t, err) expected := []definitions.NotificationTemplate{ - { - UID: legacy_storage.NameToUid("template1"), - Name: "template1", - Template: "test1", - Provenance: definitions.Provenance(models.ProvenanceAPI), - ResourceVersion: calculateTemplateFingerprint("test1"), - }, - { - UID: legacy_storage.NameToUid("template2"), - Name: "template2", - Template: "test2", - Provenance: definitions.Provenance(models.ProvenanceFile), - ResourceVersion: calculateTemplateFingerprint("test2"), - }, - { - UID: legacy_storage.NameToUid("template3"), - Name: "template3", - Template: "test3", - Provenance: definitions.Provenance(models.ProvenanceNone), - ResourceVersion: calculateTemplateFingerprint("test3"), - }, + newNotificationTemplate( + "template1", + "test1", + models.ProvenanceAPI, + definition.GrafanaTemplateKind, + ), + newNotificationTemplate( + "template2", + "test2", + models.ProvenanceFile, + definition.GrafanaTemplateKind, + ), + newNotificationTemplate( + "template3", + "test3", + models.ProvenanceNone, + definition.GrafanaTemplateKind, + ), } require.EqualValues(t, expected, result) @@ -89,6 +96,60 @@ func TestGetTemplates(t *testing.T) { prov.AssertExpectations(t) }) + t.Run("returns imported templates if enabled", func(t *testing.T) { + sut, store, prov := createTemplateServiceSut() + sut = sut.WithIncludeImported() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + assert.Equal(t, orgID, org) + return revision, nil + } + prov.EXPECT().GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(map[string]models.Provenance{ + "template1": models.ProvenanceAPI, + "template2": models.ProvenanceFile, + }, nil) + + result, err := sut.GetTemplates(context.Background(), orgID) + require.NoError(t, err) + + expected := []definitions.NotificationTemplate{ + newNotificationTemplate( + "template1", + "test1", + models.ProvenanceAPI, + definition.GrafanaTemplateKind, + ), + newNotificationTemplate( + "template2", + "test2", + models.ProvenanceFile, + definition.GrafanaTemplateKind, + ), + newNotificationTemplate( + "template3", + "test3", + models.ProvenanceNone, + definition.GrafanaTemplateKind, + ), + newNotificationTemplate( + "template1", + "imported-test1", + models.ProvenanceConvertedPrometheus, + definition.MimirTemplateKind, + ), + newNotificationTemplate( + "template4", + "imported-test4", + models.ProvenanceConvertedPrometheus, + definition.MimirTemplateKind, + ), + } + + require.EqualValues(t, expected, result) + + prov.AssertCalled(t, "GetProvenances", mock.Anything, orgID, (&definitions.NotificationTemplate{}).ResourceType()) + prov.AssertExpectations(t) + }) + t.Run("propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut, store, prov := createTemplateServiceSut() @@ -127,15 +188,25 @@ func TestGetTemplate(t *testing.T) { orgID := int64(1) templateName := "template1" templateContent := "test1" + importedTemplateName := "template2" + importedTemplateContent := "imported" revision := &legacy_storage.ConfigRevision{ Config: &definitions.PostableUserConfig{ TemplateFiles: map[string]string{ templateName: templateContent, }, + ExtraConfigs: []definitions.ExtraConfiguration{ + { + Identifier: "1234", + TemplateFiles: map[string]string{ + importedTemplateName: importedTemplateContent, + }, + }, + }, }, } - t.Run("return a template from config file by name", func(t *testing.T) { + t.Run("return a template from config by name", func(t *testing.T) { sut, store, prov := createTemplateServiceSut() store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { assert.Equal(t, orgID, org) @@ -146,13 +217,12 @@ func TestGetTemplate(t *testing.T) { result, err := sut.GetTemplate(context.Background(), orgID, templateName) require.NoError(t, err) - expected := definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(templateName), - Name: templateName, - Template: templateContent, - Provenance: definitions.Provenance(models.ProvenanceAPI), - ResourceVersion: calculateTemplateFingerprint(templateContent), - } + expected := newNotificationTemplate( + templateName, + templateContent, + models.ProvenanceAPI, + definition.GrafanaTemplateKind, + ) require.Equal(t, expected, result) @@ -162,6 +232,62 @@ func TestGetTemplate(t *testing.T) { prov.AssertExpectations(t) }) + t.Run("imported templates cannot be retrieved by name", func(t *testing.T) { + sut, store, _ := createTemplateServiceSut() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + assert.Equal(t, orgID, org) + return revision, nil + } + _, err := sut.GetTemplate(context.Background(), orgID, importedTemplateName) + require.ErrorIs(t, err, ErrTemplateNotFound) + }) + + t.Run("return a template from config by UID", func(t *testing.T) { + sut, store, prov := createTemplateServiceSut() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + assert.Equal(t, orgID, org) + return revision, nil + } + prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil) + + result, err := sut.GetTemplate(context.Background(), orgID, templateUID(definition.GrafanaTemplateKind, templateName)) + require.NoError(t, err) + + expected := newNotificationTemplate( + templateName, + templateContent, + models.ProvenanceNone, + definition.GrafanaTemplateKind, + ) + require.Equal(t, expected, result) + }) + + t.Run("return an imported template from config by UID", func(t *testing.T) { + sut, store, prov := createTemplateServiceSut() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + assert.Equal(t, orgID, org) + return revision, nil + } + + uid := templateUID(definition.MimirTemplateKind, importedTemplateName) + t.Run("should be not found without flag enabled", func(t *testing.T) { + _, err := sut.GetTemplate(context.Background(), orgID, uid) + require.ErrorIs(t, err, ErrTemplateNotFound) + }) + + result, err := sut.WithIncludeImported().GetTemplate(context.Background(), orgID, uid) + require.NoError(t, err) + + expected := newNotificationTemplate( + importedTemplateName, + importedTemplateContent, + models.ProvenanceConvertedPrometheus, + definition.MimirTemplateKind, + ) + require.Equal(t, expected, result) + prov.AssertExpectations(t) + }) + t.Run("returns ErrTemplateNotFound when template does not exist", func(t *testing.T) { sut, store, prov := createTemplateServiceSut() store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { @@ -242,18 +368,18 @@ func TestUpsertTemplate(t *testing.T) { Template: "{{ define \"test\"}} test {{ end }}", Provenance: definitions.Provenance(models.ProvenanceAPI), ResourceVersion: "", + Kind: definition.GrafanaTemplateKind, } result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - require.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + require.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Len(t, store.Calls, 2) @@ -284,18 +410,18 @@ func TestUpsertTemplate(t *testing.T) { Template: "{{ define \"test\"}} test {{ end }}", Provenance: definitions.Provenance(models.ProvenanceAPI), ResourceVersion: calculateTemplateFingerprint("test1"), + Kind: definition.GrafanaTemplateKind, } result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - assert.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + assert.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Len(t, store.Calls, 2) require.Equal(t, "Save", store.Calls[1].Method) @@ -326,13 +452,12 @@ func TestUpsertTemplate(t *testing.T) { result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - assert.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + assert.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + definition.GrafanaTemplateKind, + ), result) require.Equal(t, "Save", store.Calls[1].Method) saved := store.Calls[1].Args[1].(*legacy_storage.ConfigRevision) @@ -356,18 +481,18 @@ func TestUpsertTemplate(t *testing.T) { Template: "content", Provenance: definitions.Provenance(models.ProvenanceNone), ResourceVersion: calculateTemplateFingerprint(currentTemplateContent), + Kind: definition.GrafanaTemplateKind, } result, _ := sut.UpsertTemplate(context.Background(), orgID, tmpl) expectedContent := fmt.Sprintf("{{ define \"%s\" }}\n content\n{{ end }}", templateName) - require.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: expectedContent, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(expectedContent), - }, result) + require.Equal(t, newNotificationTemplate( + tmpl.Name, + expectedContent, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) }) t.Run("does not reject template with unknown field", func(t *testing.T) { @@ -489,6 +614,21 @@ func TestUpsertTemplate(t *testing.T) { require.ErrorIs(t, err, ErrTemplateNotFound) }) + t.Run("rejects new templates of mimir kind", func(t *testing.T) { + sut, store, _ := createTemplateServiceSut() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + return revision(), nil + } + template := definitions.NotificationTemplate{ + Name: "template2", + Template: "asdf-new", + Provenance: definitions.Provenance(models.ProvenanceNone), + Kind: definition.MimirTemplateKind, + } + _, err := sut.UpsertTemplate(context.Background(), orgID, template) + require.ErrorIs(t, err, ErrTemplateInvalid) + }) + t.Run("propagates errors", func(t *testing.T) { tmpl := definitions.NotificationTemplate{ Name: templateName, @@ -562,6 +702,7 @@ func TestCreateTemplate(t *testing.T) { Name: "new-template", Template: "{{ define \"test\"}} test {{ end }}", Provenance: definitions.Provenance(models.ProvenanceAPI), + Kind: definition.GrafanaTemplateKind, } revision := func() *legacy_storage.ConfigRevision { @@ -588,13 +729,12 @@ func TestCreateTemplate(t *testing.T) { result, err := sut.CreateTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - require.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + require.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Len(t, store.Calls, 2) @@ -649,10 +789,33 @@ func TestCreateTemplate(t *testing.T) { require.ErrorIs(t, err, ErrTemplateInvalid) }) + t.Run("invalid kind", func(t *testing.T) { + tmpl := definitions.NotificationTemplate{ + Name: "new-template", + Template: "{{ define \"test\"}} test {{ end }}", + Kind: "unknown", + } + _, err := sut.CreateTemplate(context.Background(), orgID, tmpl) + require.ErrorIs(t, err, ErrTemplateInvalid) + }) + require.Empty(t, store.Calls) prov.AssertExpectations(t) }) + t.Run("rejects templates with mimir kind", func(t *testing.T) { + sut, _, _ := createTemplateServiceSut() + + tmpl := definitions.NotificationTemplate{ + Name: "new-template", + Template: "{{ define \"test\"}} test {{ end }}", + Kind: definition.MimirTemplateKind, + } + + _, err := sut.CreateTemplate(context.Background(), orgID, tmpl) + require.ErrorIs(t, err, ErrTemplateInvalid) + }) + t.Run("propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut, store, _ := createTemplateServiceSut() @@ -706,6 +869,7 @@ func TestUpdateTemplate(t *testing.T) { Template: "{{ define \"test\"}} test {{ end }}", Provenance: definitions.Provenance(models.ProvenanceAPI), ResourceVersion: "", + Kind: definition.GrafanaTemplateKind, } amConfigToken := util.GenerateShortUID() @@ -771,7 +935,7 @@ func TestUpdateTemplate(t *testing.T) { }, { name: "by uid", - templateUid: legacy_storage.NameToUid(tmpl.UID), + templateUid: templateUID(tmpl.Kind, tmpl.Name), }, } @@ -791,13 +955,12 @@ func TestUpdateTemplate(t *testing.T) { result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - assert.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + assert.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Len(t, store.Calls, 2) require.Equal(t, "Save", store.Calls[1].Method) @@ -821,13 +984,12 @@ func TestUpdateTemplate(t *testing.T) { result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - assert.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + assert.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Equal(t, "Save", store.Calls[1].Method) saved := store.Calls[1].Args[1].(*legacy_storage.ConfigRevision) @@ -853,18 +1015,17 @@ func TestUpdateTemplate(t *testing.T) { oldName := tmpl.Name tmpl := tmpl - tmpl.UID = legacy_storage.NameToUid(tmpl.Name) // UID matches the current template - tmpl.Name = "new-template-name" // but name is different + tmpl.UID = templateUID(tmpl.Kind, tmpl.Name) // UID matches the current template + tmpl.Name = "new-template-name" // but name is different result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl) require.NoError(t, err) - assert.Equal(t, definitions.NotificationTemplate{ - UID: legacy_storage.NameToUid(tmpl.Name), - Name: tmpl.Name, - Template: tmpl.Template, - Provenance: tmpl.Provenance, - ResourceVersion: calculateTemplateFingerprint(tmpl.Template), - }, result) + assert.Equal(t, newNotificationTemplate( + tmpl.Name, + tmpl.Template, + models.Provenance(tmpl.Provenance), + tmpl.Kind, + ), result) require.Len(t, store.Calls, 2) require.Equal(t, "Save", store.Calls[1].Method) @@ -882,6 +1043,7 @@ func TestUpdateTemplate(t *testing.T) { t.Run("rejects rename operation if template with the new name exists", func(t *testing.T) { sut, store, prov := createTemplateServiceSut() + prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil) store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { return &legacy_storage.ConfigRevision{ Config: &definitions.PostableUserConfig{ @@ -895,8 +1057,8 @@ func TestUpdateTemplate(t *testing.T) { } tmpl := tmpl - tmpl.UID = legacy_storage.NameToUid(tmpl.Name) // UID matches the current template - tmpl.Name = "new-template-name" // but name matches another existing template + tmpl.UID = templateUID(tmpl.Kind, tmpl.Name) // UID matches the current template + tmpl.Name = "new-template-name" // but name matches another existing template _, err := sut.UpdateTemplate(context.Background(), orgID, tmpl) require.ErrorIs(t, err, ErrTemplateExists) @@ -925,6 +1087,16 @@ func TestUpdateTemplate(t *testing.T) { require.ErrorIs(t, err, ErrTemplateInvalid) }) + t.Run("invalid kind", func(t *testing.T) { + tmpl := definitions.NotificationTemplate{ + Name: "", + Template: "", + Kind: "unknown", + } + _, err := sut.UpdateTemplate(context.Background(), orgID, tmpl) + require.ErrorIs(t, err, ErrTemplateInvalid) + }) + require.Empty(t, store.Calls) prov.AssertExpectations(t) }) @@ -975,6 +1147,27 @@ func TestUpdateTemplate(t *testing.T) { prov.AssertExpectations(t) }) + t.Run("rejects existing templates if kind changes", func(t *testing.T) { + sut, store, prov := createTemplateServiceSut() + store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) { + return revision(), nil + } + prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil) + + template := definitions.NotificationTemplate{ + Name: "template1", + Template: "asdf-new", + ResourceVersion: "bad-version", + Provenance: definitions.Provenance(models.ProvenanceNone), + Kind: definition.MimirTemplateKind, + } + + _, err := sut.UpdateTemplate(context.Background(), orgID, template) + + require.ErrorIs(t, err, ErrTemplateInvalid) + prov.AssertExpectations(t) + }) + t.Run("propagates errors", func(t *testing.T) { t.Run("when unable to read config", func(t *testing.T) { sut, store, _ := createTemplateServiceSut() @@ -1062,7 +1255,7 @@ func TestDeleteTemplate(t *testing.T) { }, { name: "by uid", - templateNameOrUid: legacy_storage.NameToUid(templateName), + templateNameOrUid: templateUID(definition.GrafanaTemplateKind, templateName), }, } for _, tt := range testCase { @@ -1125,7 +1318,7 @@ func TestDeleteTemplate(t *testing.T) { } t.Run("should look by name before uid", func(t *testing.T) { - expectedToDelete := legacy_storage.NameToUid(templateName) + expectedToDelete := templateUID(definition.GrafanaTemplateKind, templateName) sut, store, prov := createTemplateServiceSut() store.GetFn = func(ctx context.Context, orgID int64) (*legacy_storage.ConfigRevision, error) { return &legacy_storage.ConfigRevision{ diff --git a/pkg/services/pluginsintegration/installsync/syncer.go b/pkg/services/pluginsintegration/installsync/syncer.go index 6352f7900fa..7c483d21d01 100644 --- a/pkg/services/pluginsintegration/installsync/syncer.go +++ b/pkg/services/pluginsintegration/installsync/syncer.go @@ -217,7 +217,6 @@ func (s *syncer) syncNamespace(ctx context.Context, namespace string, source ins err := s.installRegistrar.Register(ctx, namespace, &install.PluginInstall{ ID: p.ID, Version: p.Info.Version, - Class: install.Class(p.Class), Source: source, }) if err != nil { diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index 8dccc2c3522..f4504d254c5 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -17,6 +17,7 @@ type Plugin struct { // App fields Parent *ParentPlugin + Children []string IncludedInAppID string DefaultNavURL string Pinned bool @@ -85,6 +86,18 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { dto.Parent = &ParentPlugin{ID: p.Parent.ID} } + if len(p.Children) > 0 { + children := make([]string, 0, len(p.Children)) + for _, child := range p.Children { + if child != nil { + children = append(children, child.ID) + } + } + if len(children) > 0 { + dto.Children = children + } + } + return dto } diff --git a/pkg/services/sqlstore/migrations/cloud_migrations.go b/pkg/services/sqlstore/migrations/cloud_migrations.go index 7a602bf7d42..c6240037d04 100644 --- a/pkg/services/sqlstore/migrations/cloud_migrations.go +++ b/pkg/services/sqlstore/migrations/cloud_migrations.go @@ -198,10 +198,11 @@ func addCloudMigrationsMigrations(mg *Migrator) { Postgres("ALTER TABLE cloud_migration_resource ALTER COLUMN resource_uid TYPE VARCHAR(255);")) mg.AddMigration("create cloud_migration_snapshot_partition table v1", NewAddTableMigration(migrationSnapshotPartitionTable)) - mg.AddMigration("add cloud_migration_snapshot_partition srp_unique index", NewAddIndexMigration(migrationSnapshotPartitionTable, &Index{ + srpUniqueIndex := Index{ Name: "srp_unique", Cols: []string{"snapshot_uid", "resource_type", "partition_number"}, Type: UniqueIndex, - })) + } + mg.AddMigration("add cloud_migration_snapshot_partition srp_unique index", NewAddIndexMigration(migrationSnapshotPartitionTable, &srpUniqueIndex)) mg.AddMigration("add resource_storage_type column to cloud_migration_snapshot table", NewAddColumnMigration(migrationSnapshotTable, &Column{ Name: "resource_storage_type", Type: DB_Varchar, @@ -224,4 +225,16 @@ func addCloudMigrationsMigrations(mg *Migrator) { Type: DB_Blob, Nullable: true, })) + + updatedCloudMigrationSnapshotPartitionTable := Table{ + Name: "cloud_migration_snapshot_partition", + Columns: []*Column{ + {Name: "snapshot_uid", Type: DB_NVarchar, Length: 40, Nullable: false, IsPrimaryKey: true}, + {Name: "partition_number", Type: DB_Int, Nullable: false, IsPrimaryKey: true}, + {Name: "resource_type", Type: DB_Varchar, Length: 255, Nullable: false, IsPrimaryKey: true}, + {Name: "data", Type: DB_LongBlob, Nullable: false}, + }, + PrimaryKeys: []string{"snapshot_uid", "resource_type", "partition_number"}, + } + ConvertUniqueKeyToPrimaryKey(mg, srpUniqueIndex, updatedCloudMigrationSnapshotPartitionTable) } diff --git a/pkg/services/sqlstore/migrations/db_file_storage.go b/pkg/services/sqlstore/migrations/db_file_storage.go index e8ca9348455..c44344307ac 100644 --- a/pkg/services/sqlstore/migrations/db_file_storage.go +++ b/pkg/services/sqlstore/migrations/db_file_storage.go @@ -91,7 +91,7 @@ func convertFilePathHashIndexToPrimaryKey(mg *migrator.Migrator) { mg.AddMigration("drop file_path unique index from file table if it exists (mysql)", mysqlMigration2) mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file ADD PRIMARY KEY (path_hash);`) - mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file", ColumnName: "path_hash"} + mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file"} mg.AddMigration("add primary key to file table if it doesn't exist (mysql)", mysqlMigration3) postgres := ` @@ -162,7 +162,7 @@ func convertFileMetaPathHashKeyIndexToPrimaryKey(mg *migrator.Migrator) { mg.AddMigration("drop file_path unique index from file_meta table if it exists (mysql)", mysqlMigration2) mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file_meta ADD PRIMARY KEY (path_hash, ` + "`key`" + `);`) - mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file_meta", ColumnName: "path_hash"} + mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file_meta"} mg.AddMigration("add primary key to file_meta table if it doesn't exist (mysql)", mysqlMigration3) postgres := ` diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index bec421e36e2..623bada2253 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -253,7 +253,7 @@ func (b *BaseDialect) CopyTableData(sourceTable string, targetTable string, sour targetColsSQL := b.QuoteColList(targetCols) quote := b.dialect.Quote - return fmt.Sprintf("INSERT INTO %s (%s) SELECT %s FROM %s", quote(targetTable), targetColsSQL, sourceColsSQL, quote(sourceTable)) + return fmt.Sprintf("INSERT INTO %s (%s)\nSELECT %s\nFROM %s", quote(targetTable), targetColsSQL, sourceColsSQL, quote(sourceTable)) } func (b *BaseDialect) DropTable(tableName string) string { diff --git a/pkg/services/sqlstore/migrator/migrations.go b/pkg/services/sqlstore/migrator/migrations.go index b785fd867d5..ebc9573d111 100644 --- a/pkg/services/sqlstore/migrator/migrations.go +++ b/pkg/services/sqlstore/migrator/migrations.go @@ -1,6 +1,8 @@ package migrator import ( + "fmt" + "slices" "strings" ) @@ -271,3 +273,155 @@ func NewTableCharsetMigration(tableName string, columns []*Column) *TableCharset func (m *TableCharsetMigration) SQL(d Dialect) string { return d.UpdateTableSQL(m.tableName, m.columns) } + +type addPrimaryKeyMigration struct { + MigrationBase + tableName string + uniqueKey Index + + // Used for Sqlite recreation of the table. Temporary table will have tableName + "_new" suffix. + table Table +} + +func (m *addPrimaryKeyMigration) SQL(d Dialect) string { + if d.DriverName() == SQLite { + // Final SQL will do following in the individual statements: + // 1. Create new temporary table + // 2. Copy data from old table to temporary table + // 3. Drop old table, rename temporary table to original name + // 4. Recreate indexes for table. + // + // For example: + // + // CREATE TABLE file_new + // ( + // path TEXT NOT NULL, + // path_hash TEXT NOT NULL, + // parent_folder_path_hash TEXT NOT NULL, + // contents BLOB NOT NULL, + // etag TEXT NOT NULL, + // cache_control TEXT NOT NULL, + // content_disposition TEXT NOT NULL, + // updated DATETIME NOT NULL, + // created DATETIME NOT NULL, + // size INTEGER NOT NULL, + // mime_type TEXT NOT NULL, + // + // PRIMARY KEY (path_hash) + // ); + // + // INSERT INTO file_new (path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type) + // SELECT path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type FROM file; + // + // DROP TABLE file; + // ALTER TABLE file_new RENAME TO file; + // + // CREATE INDEX IDX_file_parent_folder_path_hash ON file (parent_folder_path_hash); + + tempTable := m.table + tempTable.Name = m.tableName + "_new" + + statements := strings.Builder{} + + statements.WriteString(d.CreateTableSQL(&tempTable)) + statements.WriteString("\n") // CreateTableSQL adds semicolon + + cols := make([]string, 0, len(tempTable.Columns)) + for _, col := range tempTable.Columns { + cols = append(cols, col.Name) + } + statements.WriteString(d.CopyTableData(m.tableName, tempTable.Name, cols, cols)) + statements.WriteString(";\n") + + statements.WriteString(d.DropTable(m.tableName)) + statements.WriteString(";\n") + + statements.WriteString(d.RenameTable(tempTable.Name, m.tableName)) + statements.WriteString(";\n") + + for _, idx := range tempTable.Indices { + // Use real table name, not temporary one now + statements.WriteString(d.CreateIndexSQL(m.tableName, idx)) + statements.WriteString("\n") // CreateIndexSQL adds semicolon + } + + return statements.String() + } else if d.DriverName() == Postgres { + quotesCols := make([]string, 0, len(m.uniqueKey.Cols)) + for _, c := range m.uniqueKey.Cols { + quotesCols = append(quotesCols, d.Quote(c)) + } + + return fmt.Sprintf(` + DO $$ + BEGIN + -- Drop the unique constraint if it exists + DROP INDEX IF EXISTS %s; + + -- Add primary key if it doesn't already exist + IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = '%s'::regclass AND indisprimary) THEN + ALTER TABLE %s ADD PRIMARY KEY (%s); + END IF; + END $$;`, d.Quote(m.uniqueKey.XName(m.tableName)), m.tableName, d.Quote(m.tableName), strings.Join(quotesCols, ",")) + } else { + return "" + } +} + +// ConvertUniqueKeyToPrimaryKey adds series of migrations to convert existing unique key to PRIMARY KEY. +// For Sqlite this means recreating the table, which only works if there are no foreign keys referencing the table. +func ConvertUniqueKeyToPrimaryKey(mg *Migrator, uniqueKey Index, finalTable Table) { + tableName := finalTable.Name + if tableName == "" { + panic("invalid table name") + } + if len(uniqueKey.Cols) == 0 || uniqueKey.Type != UniqueIndex { + panic("invalid unique type") + } + if !slices.Equal(uniqueKey.Cols, finalTable.PrimaryKeys) { + panic("invalid primary key in the final table") + } + + colPks := map[string]bool{} + for _, col := range finalTable.Columns { + if col.IsPrimaryKey { + colPks[col.Name] = true + } + } + for _, c := range uniqueKey.Cols { + if !colPks[c] { + panic(fmt.Sprintf("column %s is not part of primary key in the table definition", c)) + } + } + + columnsList := strings.Join(uniqueKey.Cols, ",") + + mysqlQuote := NewDialect(MySQL).Quote + mysqlQuotedColumns := make([]string, 0, len(uniqueKey.Cols)) + for _, col := range uniqueKey.Cols { + mysqlQuotedColumns = append(mysqlQuotedColumns, mysqlQuote(col)) + } + + // migration 1 is to handle cases where the table was created with sql_generate_invisible_primary_key = ON + // in this case we need to do the conversion in one sql statement + mysqlMigration1 := NewRawSQLMigration("").Mysql(fmt.Sprintf(` + ALTER TABLE %s + DROP PRIMARY KEY, + DROP COLUMN my_row_id, + DROP INDEX %s, + ADD PRIMARY KEY (%s); + `, tableName, uniqueKey.XName(tableName), strings.Join(mysqlQuotedColumns, ","))) + mysqlMigration1.Condition = &IfColumnExistsCondition{TableName: tableName, ColumnName: "my_row_id"} + mg.AddMigration(fmt.Sprintf("drop my_row_id and add primary key with columns %s to table %s if my_row_id exists (auto-generated mysql column)", columnsList, tableName), mysqlMigration1) + + mysqlMigration2 := NewRawSQLMigration("").Mysql(fmt.Sprintf(`ALTER TABLE %s DROP INDEX %s`, tableName, uniqueKey.XName(tableName))) + mysqlMigration2.Condition = &IfIndexExistsCondition{TableName: tableName, IndexName: uniqueKey.XName(tableName)} + mg.AddMigration(fmt.Sprintf("drop unique index %s from %s table if it exists (mysql)", uniqueKey.XName(tableName), tableName), mysqlMigration2) + + mysqlMigration3 := NewRawSQLMigration("").Mysql(fmt.Sprintf(`ALTER TABLE %s ADD PRIMARY KEY (%s)`, tableName, strings.Join(mysqlQuotedColumns, ","))) + mysqlMigration3.Condition = &IfPrimaryKeyNotExistsCondition{TableName: tableName} + mg.AddMigration(fmt.Sprintf("add primary key with columns %s to table %s if it doesn't exist (mysql)", columnsList, tableName), mysqlMigration3) + + // postgres and sqlite statements are idempotent so we can have only one condition-less migration + mg.AddMigration(fmt.Sprintf("add primary key with columns %s to table %s (postgres and sqlite)", columnsList, tableName), &addPrimaryKeyMigration{tableName: tableName, uniqueKey: uniqueKey, table: finalTable}) +} diff --git a/pkg/services/sqlstore/migrator/migrations_test.go b/pkg/services/sqlstore/migrator/migrations_test.go new file mode 100644 index 00000000000..ebf7c4f48f6 --- /dev/null +++ b/pkg/services/sqlstore/migrator/migrations_test.go @@ -0,0 +1,79 @@ +package migrator + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/require" +) + +//go:embed testdata/sqlite_file_migration_statement.sql +var sqliteMigrationStatement string + +func TestConvertUniqueKeyToPrimaryKey(t *testing.T) { + names := []string{ + "drop my_row_id and add primary key with columns path_hash,etag to table file if my_row_id exists (auto-generated mysql column)", + "drop unique index UQE_file_path_hash_etag from file table if it exists (mysql)", + "add primary key with columns path_hash,etag to table file if it doesn't exist (mysql)", + "add primary key with columns path_hash,etag to table file (postgres and sqlite)", + } + expectedMigrations := map[string][]ExpectedMigration{ + MySQL: { + {Id: names[0], SQL: ` + ALTER TABLE file + DROP PRIMARY KEY, + DROP COLUMN my_row_id, + DROP INDEX UQE_file_path_hash_etag, + ADD PRIMARY KEY (` + "`path_hash`" + `,` + "`etag`" + `);`}, + {Id: names[1], SQL: "ALTER TABLE file DROP INDEX UQE_file_path_hash_etag"}, + {Id: names[2], SQL: "ALTER TABLE file ADD PRIMARY KEY (`path_hash`,`etag`)"}, + {Id: names[3], SQL: ""}, + }, + Postgres: { + {Id: names[0], SQL: ""}, + {Id: names[1], SQL: ""}, + {Id: names[2], SQL: ""}, + {Id: names[3], SQL: ` + DO $$ + BEGIN + -- Drop the unique constraint if it exists + DROP INDEX IF EXISTS "UQE_file_path_hash_etag"; + + -- Add primary key if it doesn't already exist + IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = 'file'::regclass AND indisprimary) THEN + ALTER TABLE "file" ADD PRIMARY KEY ("path_hash","etag"); + END IF; + END $$;`}, + }, + SQLite: { + {Id: names[0], SQL: ""}, + {Id: names[1], SQL: ""}, + {Id: names[2], SQL: ""}, + {Id: names[3], SQL: sqliteMigrationStatement}, // Embed used here because sqlite statement is full of backquotes. + }, + } + + for dialectName, migrations := range expectedMigrations { + t.Run(dialectName, func(t *testing.T) { + err := CheckExpectedMigrations(dialectName, migrations, func(migrator *Migrator) { + ConvertUniqueKeyToPrimaryKey(migrator, + Index{Cols: []string{"path_hash", "etag"}, Type: UniqueIndex}, // Convert this unique key to primary key + Table{ + Name: "file", + Columns: []*Column{ + {Name: "path", Type: DB_NVarchar, Length: 1024, Nullable: false}, + {Name: "path_hash", Type: DB_NVarchar, Length: 64, Nullable: false, IsPrimaryKey: true}, + {Name: "parent_folder_path_hash", Type: DB_NVarchar, Length: 64, Nullable: false}, + {Name: "contents", Type: DB_Blob, Nullable: false}, + {Name: "etag", Type: DB_NVarchar, Length: 32, Nullable: false, IsPrimaryKey: true}, + }, + PrimaryKeys: []string{"path_hash", "etag"}, + Indices: []*Index{ + {Cols: []string{"parent_folder_path_hash"}}, + }, + }) + }) + require.NoError(t, err) + }) + } +} diff --git a/pkg/services/sqlstore/migrator/migrator.go b/pkg/services/sqlstore/migrator/migrator.go index 75ebf8e540b..88a2c65d057 100644 --- a/pkg/services/sqlstore/migrator/migrator.go +++ b/pkg/services/sqlstore/migrator/migrator.go @@ -8,7 +8,6 @@ import ( _ "github.com/go-sql-driver/mysql" "github.com/golang-migrate/migrate/v4/database" - "github.com/grafana/grafana/pkg/util/sqlite" _ "github.com/lib/pq" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" @@ -17,6 +16,8 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/atomic" + "github.com/grafana/grafana/pkg/util/sqlite" + "github.com/grafana/grafana/pkg/util/xorm" "github.com/grafana/grafana/pkg/infra/log" @@ -67,12 +68,16 @@ func NewMigrator(engine *xorm.Engine, cfg *setting.Cfg) *Migrator { // NewScopedMigrator should only be used for the transition to a new storage engine func NewScopedMigrator(engine *xorm.Engine, cfg *setting.Cfg, scope string) *Migrator { + return newMigrator(engine, cfg, scope, NewDialect(engine.DriverName())) +} + +func newMigrator(engine *xorm.Engine, cfg *setting.Cfg, scope string, dialect Dialect) *Migrator { mg := &Migrator{ Cfg: cfg, DBEngine: engine, migrations: make([]Migration, 0), migrationIds: make(map[string]struct{}), - Dialect: NewDialect(engine.DriverName()), + Dialect: dialect, metrics: migratorMetrics{ migCount: prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: "grafana_database", diff --git a/pkg/services/sqlstore/migrator/testdata/sqlite_file_migration_statement.sql b/pkg/services/sqlstore/migrator/testdata/sqlite_file_migration_statement.sql new file mode 100644 index 00000000000..2f37839d842 --- /dev/null +++ b/pkg/services/sqlstore/migrator/testdata/sqlite_file_migration_statement.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS `file_new` ( + `path` TEXT NOT NULL + , `path_hash` TEXT NOT NULL + , `parent_folder_path_hash` TEXT NOT NULL + , `contents` BLOB NOT NULL + , `etag` TEXT NOT NULL + , PRIMARY KEY ( `path_hash`,`etag` )); + +INSERT INTO `file_new` (`path` + , `path_hash` + , `parent_folder_path_hash` + , `contents` + , `etag`) +SELECT `path` + , `path_hash` + , `parent_folder_path_hash` + , `contents` + , `etag` +FROM `file`; + +DROP TABLE IF EXISTS `file`; +ALTER TABLE `file_new` RENAME TO `file`; +CREATE INDEX `IDX_file_parent_folder_path_hash` ON `file` (`parent_folder_path_hash`); diff --git a/pkg/services/sqlstore/migrator/testing.go b/pkg/services/sqlstore/migrator/testing.go new file mode 100644 index 00000000000..cfe7849c79e --- /dev/null +++ b/pkg/services/sqlstore/migrator/testing.go @@ -0,0 +1,51 @@ +package migrator + +import ( + "fmt" + "strings" +) + +type ExpectedMigration struct { + Id string + SQL string +} + +// CheckExpectedMigrations verifies that given migrations exist in migrator after running addMigrations function, +// that they are in the same order and have expected SQL. +func CheckExpectedMigrations(dialectName string, expected []ExpectedMigration, addMigrations func(migrator *Migrator)) error { + d := NewDialect(dialectName) + mg := newMigrator(nil, nil, "", d) + addMigrations(mg) + + migrations := mg.migrations + migrationNames := make([]string, 0, len(migrations)) + for _, m := range expected { + for ; len(migrations) > 0 && migrations[0].Id() != m.Id; migrations = migrations[1:] { + migrationNames = append(migrationNames, migrations[0].Id()) + } + + if len(migrations) == 0 { + return fmt.Errorf("migration `%s` not found, existing migrations:\n%s", m.Id, strings.Join(migrationNames, "\n")) + } + + sql := migrations[0].SQL(d) + if normalizeLines(m.SQL) != normalizeLines(sql) { + return fmt.Errorf("migration `%s` has wrong SQL:\nexpected:\n%s\nactual:\n%s", m.Id, m.SQL, sql) + } + } + return nil +} + +func normalizeLines(sql string) string { + lines := strings.Split(sql, "\n") + result := strings.Builder{} + for _, l := range lines { + l := strings.TrimSpace(l) + if l == "" { + continue + } + result.WriteString(l) + result.WriteString("\n") + } + return result.String() +} diff --git a/pkg/storage/secret/migrator/migrator.go b/pkg/storage/secret/migrator/migrator.go index 215020561a2..f2a4525c082 100644 --- a/pkg/storage/secret/migrator/migrator.go +++ b/pkg/storage/secret/migrator/migrator.go @@ -224,4 +224,23 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) { mg.AddMigration("set secret_secure_value.keeper to 'system' where keeper is null in "+TableNameSecureValue, migrator.NewRawSQLMigration( fmt.Sprintf("UPDATE %s SET keeper = '%s' WHERE keeper IS NULL", TableNameSecureValue, contracts.SystemKeeperName), )) + + encryptedValueTableUniqueKey := migrator.Index{Cols: []string{"namespace", "name", "version"}, Type: migrator.UniqueIndex} + updatedEncryptedValueTable := migrator.Table{ + Name: TableNameEncryptedValue, + Columns: []*migrator.Column{ + {Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false, IsPrimaryKey: true}, // Limit enforced by K8s. + {Name: "name", Type: migrator.DB_NVarchar, Length: 253, Nullable: false, IsPrimaryKey: true}, + {Name: "version", Type: migrator.DB_BigInt, Nullable: false, IsPrimaryKey: true}, + {Name: "encrypted_data", Type: migrator.DB_Blob, Nullable: false}, + {Name: "created", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "updated", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "data_key_id", Type: migrator.DB_NVarchar, Length: 100, Nullable: false, Default: "''"}, + }, + PrimaryKeys: []string{"namespace", "name", "version"}, + Indices: []*migrator.Index{ + {Cols: []string{"data_key_id"}}, + }, + } + migrator.ConvertUniqueKeyToPrimaryKey(mg, encryptedValueTableUniqueKey, updatedEncryptedValueTable) } diff --git a/pkg/storage/unified/resource/data/sqlkv_keys.sql b/pkg/storage/unified/resource/data/sqlkv_keys.sql new file mode 100644 index 00000000000..36b7bfbe01c --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_keys.sql @@ -0,0 +1,9 @@ +SELECT {{ .Ident "key_path" }} +FROM {{ .TableName }} +WHERE {{ .Ident "key_path" }} >= {{ .Arg .StartKey }} + AND {{ .Ident "key_path" }} < {{ .Arg .EndKey }} +ORDER BY {{ .Ident "key_path" }} {{ if .SortAscending }}ASC{{ else }}DESC{{ end }} +{{ if .Options.Limit }} +LIMIT {{ .Options.Limit }} +{{ end }} +; diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 90171a77a5f..a56d00a3e12 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "iter" + "strings" "text/template" "github.com/grafana/grafana/pkg/storage/unified/sql/db" @@ -35,6 +36,7 @@ func mustTemplate(filename string) *template.Template { var ( sqlKVGet = mustTemplate("sqlkv_get.sql") sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -114,6 +116,32 @@ func (req sqlKVDeleteRequest) Validate() error { return req.sqlKVSectionKey.Validate() } +type sqlKVKeysRequest struct { + sqltemplate.SQLTemplate + sqlKVSection + Options ListOptions +} + +func (req sqlKVKeysRequest) Validate() error { + return req.sqlKVSection.Validate() +} + +func (req sqlKVKeysRequest) StartKey() string { + return req.Section + "/" + req.Options.StartKey +} + +func (req sqlKVKeysRequest) EndKey() string { + if req.Options.EndKey == "" { + req.Options.EndKey = PrefixRangeEnd(req.Section + "/") + } + + return req.Section + "/" + req.Options.EndKey +} + +func (req sqlKVKeysRequest) SortAscending() bool { + return req.Options.Sort != SortOrderDesc +} + var _ KV = &sqlKV{} type sqlKV struct { @@ -151,7 +179,31 @@ func (k *sqlKV) Ping(ctx context.Context) error { func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] { return func(yield func(string, error) bool) { - panic("not implemented!") + rows, err := dbutil.QueryRows(ctx, k.db, sqlKVKeys, sqlKVKeysRequest{ + SQLTemplate: sqltemplate.New(k.dialect), + sqlKVSection: sqlKVSection{section}, + Options: opt, + }) + if err != nil { + yield("", err) + return + } + + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + yield("", fmt.Errorf("error reading row: %w", err)) + return + } + + if !yield(strings.TrimPrefix(key, section+"/"), nil) { + return + } + } + + if err := rows.Err(); err != nil { + yield("", fmt.Errorf("failed to read rows: %w", err)) + } } } diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index fbbfe32d4a6..0f19ae01d93 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -204,5 +204,18 @@ func initResourceTables(mg *migrator.Migrator) string { Name: "IDX_resource_history_key_path", })) + oldResourceVersionUniqueKey := migrator.Index{Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex} + updatedResourceVersionTable := migrator.Table{ + Name: "resource_version", + Columns: []*migrator.Column{ + {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false, IsPrimaryKey: true}, + {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false, IsPrimaryKey: true}, + {Name: "resource_version", Type: migrator.DB_BigInt, Nullable: false}, + }, + PrimaryKeys: []string{"group", "resource"}, + } + + migrator.ConvertUniqueKeyToPrimaryKey(mg, oldResourceVersionUniqueKey, updatedResourceVersionTable) + return marker } diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index fbdb8c70d20..b4c61f84c13 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -88,8 +88,17 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) { } } -func prefixKey(nsPrefix, key string) string { - return nsPrefix + "/" + key +func namespacedKeys(nsPrefix string, keys []string) []string { + prefixed := make([]string, 0, len(keys)) + for _, k := range keys { + prefixed = append(prefixed, nsPrefix+"/"+k) + } + + return prefixed +} + +func namespacedKey(nsPrefix, key string) string { + return namespacedKeys(nsPrefix, []string{key})[0] } func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { @@ -97,7 +106,7 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("get existing key", func(t *testing.T) { // First save a key - existingKey := prefixKey(nsPrefix, "existing-key") + existingKey := namespacedKey(nsPrefix, "existing-key") testValue := "test value for get" saveKVHelper(t, kv, ctx, testSection, existingKey, strings.NewReader(testValue)) @@ -116,13 +125,13 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("get non-existent key", func(t *testing.T) { - _, err := kv.Get(ctx, testSection, prefixKey(nsPrefix, "non-existent-key")) + _, err := kv.Get(ctx, testSection, namespacedKey(nsPrefix, "non-existent-key")) assert.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) }) t.Run("get with empty section", func(t *testing.T) { - _, err := kv.Get(ctx, "", prefixKey(nsPrefix, "some-key")) + _, err := kv.Get(ctx, "", namespacedKey(nsPrefix, "some-key")) assert.Error(t, err) assert.Contains(t, err.Error(), "section is required") }) @@ -215,7 +224,7 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("delete existing key", func(t *testing.T) { // First create a key - deleteKey := prefixKey(nsPrefix, "delete-key") + deleteKey := namespacedKey(nsPrefix, "delete-key") saveKVHelper(t, kv, ctx, testSection, deleteKey, strings.NewReader("delete me")) // Verify it exists @@ -233,13 +242,13 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("delete non-existent key", func(t *testing.T) { - err := kv.Delete(ctx, testSection, prefixKey(nsPrefix, "non-existent-delete-key")) + err := kv.Delete(ctx, testSection, namespacedKey(nsPrefix, "non-existent-delete-key")) assert.Error(t, err) assert.Equal(t, resource.ErrNotFound, err) }) t.Run("delete with empty section", func(t *testing.T) { - err := kv.Delete(ctx, "", prefixKey(nsPrefix, "some-key")) + err := kv.Delete(ctx, "", namespacedKey(nsPrefix, "some-key")) assert.Error(t, err) assert.Contains(t, err.Error(), "section is required") }) @@ -253,17 +262,16 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-keys" // Setup test data - testKeys := []string{"a1", "a2", "b1", "b2", "c1"} + testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}) for _, key := range testKeys { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key)) + saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key)) } t.Run("list all keys", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) { + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) { require.NoError(t, err) keys = append(keys, k) } @@ -276,134 +284,186 @@ func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) { for k, err := range kv.Keys(ctx, "", resource.ListOptions{}) { if err != nil { errors = append(errors, err) - break + continue } keys = append(keys, k) } - assert.Len(t, errors, 1) + require.Len(t, errors, 1) assert.Contains(t, errors[0].Error(), "section is required") assert.Empty(t, keys) }) + t.Run("invalid sort option, defaults to asc", func(t *testing.T) { + var keys []string + var errors []error + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + Sort: resource.SortOrder(100), + }) { + if err != nil { + errors = append(errors, err) + continue + } + keys = append(keys, k) + } + assert.Empty(t, errors) + assert.Equal(t, testKeys, keys) + }) + + t.Run("list keys with end key < start key", func(t *testing.T) { + var keys []string + var errors []error + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: namespacedKey(nsPrefix, "c"), + EndKey: namespacedKey(nsPrefix, "a"), + }) { + if err != nil { + errors = append(errors, err) + continue + } + keys = append(keys, k) + } + // Nothing is yielded + assert.Empty(t, errors) + assert.Empty(t, keys) + }) + t.Run("list keys returns 0 keys", func(t *testing.T) { - // Use a different section with no keys - emptySection := nsPrefix + "-empty-keys" + // Use a key range with no keys. + startKey, endKey := "aaaaa", "aaaaz" var keys []string - for k, err := range kv.Keys(ctx, emptySection, resource.ListOptions{}) { + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: startKey, + EndKey: endKey, + }) { require.NoError(t, err) keys = append(keys, k) } assert.Empty(t, keys) assert.Len(t, keys, 0) }) + + t.Run("interrupting the iterator", func(t *testing.T) { + var keys []string + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) { + require.NoError(t, err) + keys = append(keys, k) + + if len(keys) == 2 { + break + } + } + + assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2"}), keys) + }) } func runTestKVKeysWithLimits(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-keys-limits" // Setup test data - testKeys := []string{"a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"} + testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"}) for _, key := range testKeys { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key)) + saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key)) } t.Run("keys with limit", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{Limit: 3}) { + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Limit: 3}) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"a1", "a2", "b1"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2", "b1"}), keys) }) t.Run("keys with range", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{StartKey: "b", EndKey: "d"}) { - require.NoError(t, err) - keys = append(keys, k) - } - assert.Equal(t, []string{"b1", "b2", "c1", "c2"}, keys) - }) - - t.Run("keys with prefix", func(t *testing.T) { - var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{ - StartKey: "c", - EndKey: resource.PrefixRangeEnd("c"), + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: namespacedKey(nsPrefix, "b"), + EndKey: namespacedKey(nsPrefix, "d"), }) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"c1", "c2"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"b1", "b2", "c1", "c2"}), keys) + }) + + t.Run("keys with prefix", func(t *testing.T) { + var keys []string + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: namespacedKey(nsPrefix, "c"), + EndKey: namespacedKey(nsPrefix, resource.PrefixRangeEnd("c")), + }) { + require.NoError(t, err) + keys = append(keys, k) + } + assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "c2"}), keys) }) t.Run("keys with limit and range", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{ - StartKey: "a", - EndKey: "c", + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: namespacedKey(nsPrefix, "a"), + EndKey: namespacedKey(nsPrefix, "c"), Limit: 2, }) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"a1", "a2"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2"}), keys) }) } func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-keys-sort" // Setup test data - testKeys := []string{"a1", "a2", "b1", "b2", "c1"} + testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}) for _, key := range testKeys { - saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key)) + saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key)) } t.Run("keys in ascending order (default)", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{Sort: resource.SortOrderAsc}) { + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Sort: resource.SortOrderAsc}) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"a1", "a2", "b1", "b2", "c1"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}), keys) }) t.Run("keys in descending order", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{Sort: resource.SortOrderDesc}) { + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Sort: resource.SortOrderDesc}) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"c1", "b2", "b1", "a2", "a1"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "b2", "b1", "a2", "a1"}), keys) }) t.Run("keys descending with prefix", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{ - StartKey: "a", - EndKey: resource.PrefixRangeEnd("a"), + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ + StartKey: namespacedKey(nsPrefix, "a"), + EndKey: namespacedKey(nsPrefix, resource.PrefixRangeEnd("a")), Sort: resource.SortOrderDesc, }) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"a2", "a1"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"a2", "a1"}), keys) }) t.Run("keys descending with limit", func(t *testing.T) { var keys []string - for k, err := range kv.Keys(ctx, section, resource.ListOptions{ + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{ Sort: resource.SortOrderDesc, Limit: 3, }) { require.NoError(t, err) keys = append(keys, k) } - assert.Equal(t, []string{"c1", "b2", "b1"}, keys) + assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "b2", "b1"}), keys) }) } diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index f6db1d2f1d8..780f36fcc66 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -47,14 +47,11 @@ func TestSQLKV(t *testing.T) { }, &KVTestOptions{ NSPrefix: "sql-kv-test", SkipTests: map[string]bool{ - TestKVSave: true, - TestKVKeys: true, - TestKVKeysWithLimits: true, - TestKVKeysWithSort: true, - TestKVConcurrent: true, - TestKVUnixTimestamp: true, - TestKVBatchGet: true, - TestKVBatchDelete: true, + TestKVSave: true, + TestKVConcurrent: true, + TestKVUnixTimestamp: true, + TestKVBatchGet: true, + TestKVBatchDelete: true, }, }) } diff --git a/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go b/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go new file mode 100644 index 00000000000..9a3e88f3e46 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/templategroup/imported_test.go @@ -0,0 +1,129 @@ +package templateGroup + +import ( + "context" + "embed" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1" + "github.com/grafana/grafana/pkg/services/featuremgmt" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/api/alerting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util/testutil" +) + +//go:embed test-data/*.* +var testData embed.FS + +func TestIntegrationImportedTemplates(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{ + featuremgmt.FlagAlertingImportAlertmanagerAPI, + }, + }) + + client := common.NewTemplateGroupClient(t, helper.Org1.Admin) + + cliCfg := helper.Org1.Admin.NewRestConfig() + alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) + + configYaml, err := testData.ReadFile(path.Join("test-data", "imported.yaml")) + require.NoError(t, err) + + identifier := "test-create-get-config" + mergeMatchers := "_imported=true" + + headers := map[string]string{ + "Content-Type": "application/yaml", + "X-Grafana-Alerting-Config-Identifier": identifier, + "X-Grafana-Alerting-Merge-Matchers": mergeMatchers, + } + var amConfig apimodels.AlertmanagerUserConfig + require.NoError(t, yaml.Unmarshal(configYaml, &amConfig)) + + response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers) + require.Equal(t, "success", response.Status) + + templates, err := client.List(context.Background(), metav1.ListOptions{}) + + require.NoError(t, err) + require.Len(t, templates.Items, 3) + + require.Equal(t, v0alpha1.DefaultTemplateTitle, templates.Items[0].Spec.Title) + require.Equal(t, "imported", templates.Items[1].Spec.Title) + require.Equal(t, "template", templates.Items[2].Spec.Title) + + t.Run("should be correct kind", func(t *testing.T) { + assert.Equal(t, + v0alpha1.TemplateGroupSpec{ + Title: "imported", + Content: amConfig.TemplateFiles["imported"], + Kind: v0alpha1.TemplateGroupTemplateKindMimir, + }, templates.Items[1].Spec) + assert.Equal(t, + v0alpha1.TemplateGroupSpec{ + Title: "template", + Content: amConfig.TemplateFiles["template"], + Kind: v0alpha1.TemplateGroupTemplateKindMimir, + }, templates.Items[2].Spec) + }) + + t.Run("should be provisioned", func(t *testing.T) { + for _, tpl := range templates.Items[1:] { + assert.EqualValues(t, models.ProvenanceConvertedPrometheus, tpl.GetProvenanceStatus()) + } + }) + + t.Run("should not be able to update", func(t *testing.T) { + tpl := templates.Items[1] + tpl.Spec.Content = "new content" + _, err := client.Update(context.Background(), &tpl, metav1.UpdateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + t.Run("should not be able to delete", func(t *testing.T) { + err := client.Delete(context.Background(), templates.Items[1].Name, metav1.DeleteOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + t.Run("should not conflict with Grafana kind", func(t *testing.T) { + tpl := v0alpha1.TemplateGroup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: templates.Items[1].Spec, + } + tpl.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana + + created, err := client.Create(context.Background(), &tpl, metav1.CreateOptions{}) + require.NoError(t, err) + + assert.NotEqual(t, templates.Items[1].Name, created.Name) + }) + + t.Run("sort by kind and then name", func(t *testing.T) { + templates, err := client.List(context.Background(), metav1.ListOptions{}) + + require.NoError(t, err) + require.Len(t, templates.Items, 4) + assert.Equal(t, v0alpha1.DefaultTemplateTitle, templates.Items[0].Spec.Title) + assert.Equal(t, "imported", templates.Items[1].Spec.Title) + assert.Equal(t, v0alpha1.TemplateGroupTemplateKindGrafana, templates.Items[1].Spec.Kind) + assert.Equal(t, "imported", templates.Items[2].Spec.Title) + assert.Equal(t, v0alpha1.TemplateGroupTemplateKindMimir, templates.Items[2].Spec.Kind) + assert.Equal(t, "template", templates.Items[3].Spec.Title) + }) +} diff --git a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go index 04d9e626ab7..146bccd5f11 100644 --- a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go +++ b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go @@ -54,6 +54,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "templateGroup", Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } @@ -112,6 +113,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { require.Equal(t, v0alpha1.TemplateGroupSpec{ Title: v0alpha1.DefaultTemplateTitle, Content: defaultDefn.Template, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, actual.Spec) defaultTemplateGroup = actual }) @@ -226,6 +228,7 @@ func TestIntegrationAccessControl(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: fmt.Sprintf("template-group-1-%s", tc.user.Identity.GetLogin()), Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } expected.SetProvenanceStatus("") @@ -385,6 +388,7 @@ func TestIntegrationProvisioning(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "template-group-1", Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, }, v1.CreateOptions{}) require.NoError(t, err) @@ -428,6 +432,7 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "template-group-1", Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } @@ -510,6 +515,7 @@ func TestIntegrationPatch(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "template-group", Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } @@ -568,6 +574,7 @@ func TestIntegrationListSelector(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "test1", Content: `{{ define "test1" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } template1, err := adminClient.Create(ctx, template1, v1.CreateOptions{}) @@ -580,6 +587,7 @@ func TestIntegrationListSelector(t *testing.T) { Spec: v0alpha1.TemplateGroupSpec{ Title: "test2", Content: `{{ define "test2" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindGrafana, }, } template2, err = adminClient.Create(ctx, template2, v1.CreateOptions{}) @@ -655,3 +663,37 @@ func TestIntegrationListSelector(t *testing.T) { require.NotEqualf(t, templates.DefaultTemplateName, list.Items[1].Name, "Expected non-default template but got %s", list.Items[1].Name) }) } + +func TestIntegrationKinds(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := getTestHelper(t) + client := common.NewTemplateGroupClient(t, helper.Org1.Admin) + + newTemplate := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "templateGroup", + Content: `{{ define "test" }} test {{ end }}`, + Kind: v0alpha1.TemplateGroupTemplateKindMimir, + }, + } + + t.Run("should not let create Mimir template", func(t *testing.T) { + _, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + t.Run("should not let change kind", func(t *testing.T) { + newTemplate.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana + created, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + require.NoError(t, err) + + created.Spec.Kind = v0alpha1.TemplateGroupTemplateKindMimir + _, err = client.Update(ctx, created, v1.UpdateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) +} diff --git a/pkg/tests/apis/alerting/notifications/templategroup/test-data/imported.yaml b/pkg/tests/apis/alerting/notifications/templategroup/test-data/imported.yaml new file mode 100644 index 00000000000..76e9a2c4b51 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/templategroup/test-data/imported.yaml @@ -0,0 +1,15 @@ +alertmanager_config: | + route: + receiver: sinkhole + group_by: + - alertname + receivers: + - name: sinkhole +template_files: + imported: | + {{ define "imported" }} + {{ end }} + template: | + {{ define "template" }} + {{ template "imported" . }} + {{ end }} diff --git a/pkg/tests/apis/openapi_snapshots/notifications.alerting.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/notifications.alerting.grafana.app-v0alpha1.json index 2061616fc9a..4c7c45aad35 100644 --- a/pkg/tests/apis/openapi_snapshots/notifications.alerting.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/notifications.alerting.grafana.app-v0alpha1.json @@ -3783,18 +3783,29 @@ "type": "object", "required": [ "title", - "content" + "content", + "kind" ], "properties": { "content": { "type": "string" }, + "kind": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TemplateGroupTemplateKind" + }, "title": { "type": "string" } }, "additionalProperties": false }, + "com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TemplateGroupTemplateKind": { + "type": "string", + "enum": [ + "grafana", + "mimir" + ] + }, "com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TimeInterval": { "type": "object", "required": [ diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 8952906ac29..d4764b742f1 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -5295,10 +5295,6 @@ "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.MigrateJobOptions": { "type": "object", "properties": { - "history": { - "description": "Preserve history (if possible)", - "type": "boolean" - }, "message": { "description": "Message to use when committing the changes in a single commit", "type": "string" @@ -5828,10 +5824,6 @@ "kind": { "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", "type": "string" - }, - "legacyStorage": { - "description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", - "type": "boolean" } } }, diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index b9d0283e37a..f9d5cdb70e5 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -10,7 +10,9 @@ import ( "k8s.io/apimachinery/pkg/util/version" "k8s.io/client-go/kubernetes" + "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" @@ -37,6 +39,11 @@ func TestIntegrationOpenAPIs(t *testing.T) { featuremgmt.FlagKubernetesAlertingHistorian, featuremgmt.FlagKubernetesLogsDrilldown, }, + // Explicitly configure with mode 5 the resources supported by provisioning. + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, + "folders.folder.grafana.app": {DualWriterMode: rest.Mode5}, + }, }) t.Run("check valid version response", func(t *testing.T) { diff --git a/pkg/tests/apis/plugins/metas_test.go b/pkg/tests/apis/plugins/metas_test.go index c240d3fb91d..0132ee72173 100644 --- a/pkg/tests/apis/plugins/metas_test.go +++ b/pkg/tests/apis/plugins/metas_test.go @@ -28,7 +28,7 @@ func TestIntegrationPluginMeta(t *testing.T) { "apiVersion": "plugins.grafana.app/v0alpha1", "kind": "Plugin", "metadata": {"name": "%s"}, - "spec": {"id": "grafana-piechart-panel", "version": "1.0.0"} + "spec": {"id": "piechart", "version": "1.0.0"} }`, plugin1Name)) _, err := client.Resource.Create(ctx, plugin1, metav1.CreateOptions{}) require.NoError(t, err) @@ -38,7 +38,7 @@ func TestIntegrationPluginMeta(t *testing.T) { "apiVersion": "plugins.grafana.app/v0alpha1", "kind": "Plugin", "metadata": {"name": "%s"}, - "spec": {"id": "grafana-clock-panel", "version": "1.0.0"} + "spec": {"id": "table", "version": "1.0.0"} }`, plugin2Name)) _, err = client.Resource.Create(ctx, plugin2, metav1.CreateOptions{}) require.NoError(t, err) @@ -57,14 +57,14 @@ func TestIntegrationPluginMeta(t *testing.T) { foundIDs := make(map[string]bool) for _, item := range response.Result.Items { - require.NotNil(t, item.Spec.PluginJSON) - foundIDs[item.Spec.PluginJSON.Id] = true - require.NotEmpty(t, item.Spec.PluginJSON.Id) - require.NotEmpty(t, item.Spec.PluginJSON.Type) - require.NotEmpty(t, item.Spec.PluginJSON.Name) + require.NotNil(t, item.Spec.PluginJson) + foundIDs[item.Spec.PluginJson.Id] = true + require.NotEmpty(t, item.Spec.PluginJson.Id) + require.NotEmpty(t, item.Spec.PluginJson.Type) + require.NotEmpty(t, item.Spec.PluginJson.Name) } - require.True(t, foundIDs["grafana-piechart-panel"]) - require.True(t, foundIDs["grafana-clock-panel"]) + require.True(t, foundIDs["piechart"]) + require.True(t, foundIDs["table"]) }) t.Run("list plugin metas with no plugins", func(t *testing.T) { @@ -95,7 +95,7 @@ func TestIntegrationPluginMeta(t *testing.T) { "apiVersion": "plugins.grafana.app/v0alpha1", "kind": "Plugin", "metadata": {"name": "%s"}, - "spec": {"id": "grafana-piechart-panel", "version": "1.0.0"} + "spec": {"id": "piechart", "version": "1.0.0"} }`, pluginName)) _, err := client.Resource.Create(ctx, plugin, metav1.CreateOptions{}) require.NoError(t, err) @@ -109,10 +109,10 @@ func TestIntegrationPluginMeta(t *testing.T) { }, &pluginsv0alpha1.Meta{}) require.NotNil(t, response.Result) - require.NotNil(t, response.Result.Spec.PluginJSON) - require.Equal(t, "grafana-piechart-panel", response.Result.Spec.PluginJSON.Id) - require.NotEmpty(t, response.Result.Spec.PluginJSON.Name) - require.NotEmpty(t, response.Result.Spec.PluginJSON.Type) + require.NotNil(t, response.Result.Spec.PluginJson) + require.Equal(t, "piechart", response.Result.Spec.PluginJson.Id) + require.NotEmpty(t, response.Result.Spec.PluginJson.Name) + require.NotEmpty(t, response.Result.Spec.PluginJson.Type) }) t.Run("get plugin meta for non-existent plugin", func(t *testing.T) { diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 5f2ec6a3985..0b6a5e3c806 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -681,12 +681,17 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper EnableFeatureToggles: []string{ featuremgmt.FlagProvisioning, }, + // Provisioning requires resources to be fully migrated to unified storage. + // Mode5 ensures reads/writes go to unified storage, and EnableMigration + // enables the data migration at startup to migrate legacy data. UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { - DualWriterMode: grafanarest.Mode5, + DualWriterMode: grafanarest.Mode5, + EnableMigration: true, }, "folders.folder.grafana.app": { - DualWriterMode: grafanarest.Mode5, + DualWriterMode: grafanarest.Mode5, + EnableMigration: true, }, }, PermittedProvisioningPaths: ".|" + provisioningPath, diff --git a/public/app/features/auth-config/FieldRenderer.tsx b/public/app/features/auth-config/FieldRenderer.tsx index 1d80f835fb0..687079f1281 100644 --- a/public/app/features/auth-config/FieldRenderer.tsx +++ b/public/app/features/auth-config/FieldRenderer.tsx @@ -35,17 +35,23 @@ export const FieldRenderer = ({ const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured); const isDependantField = typeof field !== 'string'; const name = isDependantField ? field.name : field; - const parentValue = isDependantField ? watch(field.dependsOn) : null; + const parentValue = isDependantField && field.dependsOn ? watch(field.dependsOn) : null; const fieldData = fieldMap(provider)[name]; const theme = useTheme2(); + + // Handle disabledWhen configuration + const disabledWhen = isDependantField ? field.disabledWhen : undefined; + const disabledWhenValue = disabledWhen ? watch(disabledWhen.field) : undefined; + const isDisabled = disabledWhen ? disabledWhenValue === disabledWhen.is : false; + // Unregister a field that depends on a toggle to clear its data useEffect(() => { - if (isDependantField) { + if (isDependantField && field.dependsOn) { if (!parentValue) { unregister(name); } } - }, [unregister, name, parentValue, isDependantField]); + }, [unregister, name, parentValue, isDependantField, field]); const isNotEmptySelectableValueArray = ( current: string | boolean | Record | Array> | undefined @@ -64,6 +70,13 @@ export const FieldRenderer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Set the value when the field is disabled + useEffect(() => { + if (isDisabled && disabledWhen?.disabledValue) { + setValue(name, disabledWhen.disabledValue.value); + } + }, [isDisabled, disabledWhen?.disabledValue, name, setValue]); + if (!field) { console.log('missing field:', name); return null; @@ -74,12 +87,12 @@ export const FieldRenderer = ({ } // Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false - if (isDependantField) { - const parentValue = watch(field.dependsOn); + if (isDependantField && field.dependsOn) { if (!parentValue) { return null; } } + const fieldProps = { label: fieldData.label, required: !!fieldData.validation?.required, @@ -131,10 +144,10 @@ export const FieldRenderer = ({ rules={fieldData.validation} name={name} control={control} - render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => { + render={({ field: { ref, onChange, ...controllerFieldProps }, fieldState: { invalid } }) => { return (