diff --git a/config/config.schema.json b/config/config.schema.json index 61b03dbf449..7f3fc62bb25 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -2786,6 +2786,15 @@ "grafanaName": { "type": "string" }, + "adxDatasourceEnabled": { + "type": "boolean", + "description": "Whether to provision ADX datasources in Managed Grafana" + }, + "adxDatasourceGeographies": { + "type": "string", + "pattern": "^$|^\\s*[A-Za-z0-9-]+\\s*(,\\s*[A-Za-z0-9-]+\\s*)*$", + "description": "Comma-separated geography allowlist for optional targeted ADX datasource provisioning/removal. Empty means all geographies. Matching is case-insensitive and surrounding whitespace is ignored." + }, "grafanaMajorVersion": { "type": "string" }, diff --git a/config/config.yaml b/config/config.yaml index 4f2b838d82c..1a4c5e224f3 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -345,6 +345,8 @@ defaults: # Monitoring monitoring: grafanaName: "arohcp-{{ .ctx.environment }}" + adxDatasourceEnabled: false + adxDatasourceGeographies: "" # Format: # Multiline string using '>-' YAML block scalar # One item per line, formatted as: UUID/PrincipalType/RoleName diff --git a/dev-infrastructure/cleanup/delete.kusto.instance.sh b/dev-infrastructure/cleanup/delete.kusto.instance.sh index cce52051ac3..77c36450242 100755 --- a/dev-infrastructure/cleanup/delete.kusto.instance.sh +++ b/dev-infrastructure/cleanup/delete.kusto.instance.sh @@ -87,7 +87,7 @@ kusto_instance_id=$(get_kusto_instance_id) if [[ -n "$kusto_instance_id" ]]; then if has_locks "$kusto_instance_id"; then log WARN "Skipping locked Kusto instance: $kusto_instance_id" - continue + exit 0 fi if [[ "$DRY_RUN" == "true" ]]; then diff --git a/dev-infrastructure/configurations/kusto.tmpl.bicepparam b/dev-infrastructure/configurations/kusto.tmpl.bicepparam index 280b15e90ea..c2b0d566a0d 100644 --- a/dev-infrastructure/configurations/kusto.tmpl.bicepparam +++ b/dev-infrastructure/configurations/kusto.tmpl.bicepparam @@ -7,6 +7,8 @@ param tier = '{{ .kusto.tier }}' param kustoName = '{{ .kusto.kustoName }}' +param grafanaResourceId = '__grafanaResourceId__' + param manageInstance = {{ .kusto.manageInstance }} param serviceLogsDatabase = '{{ .kusto.serviceLogsDatabase }}' diff --git a/dev-infrastructure/geography-pipeline.yaml b/dev-infrastructure/geography-pipeline.yaml index 833d32988f0..8de2fac81b6 100644 --- a/dev-infrastructure/geography-pipeline.yaml +++ b/dev-infrastructure/geography-pipeline.yaml @@ -2,6 +2,16 @@ $schema: pipeline.schema.v1 rolloutName: Geography Rollout serviceGroup: Microsoft.Azure.ARO.HCP.Geography resourceGroups: +- name: global + resourceGroup: '{{ .global.rg }}' + subscription: '{{ .global.subscription.key }}' + steps: + - name: output + action: ARM + template: templates/output-global.bicep + parameters: configurations/output-global.tmpl.bicepparam + deploymentLevel: ResourceGroup + outputOnly: true - name: kusto-infra resourceGroup: '{{ .kusto.rg }}' subscription: '{{ .global.subscription.key }}' @@ -38,3 +48,12 @@ resourceGroups: template: templates/kusto.bicep parameters: configurations/kusto.tmpl.bicepparam deploymentLevel: ResourceGroup + variables: + - name: grafanaResourceId + input: + resourceGroup: global + step: output + name: grafanaResourceId + dependsOn: + - resourceGroup: global + step: output diff --git a/dev-infrastructure/modules/logs/kusto/cluster.bicep b/dev-infrastructure/modules/logs/kusto/cluster.bicep index e81d4f891f3..75c2160a77b 100644 --- a/dev-infrastructure/modules/logs/kusto/cluster.bicep +++ b/dev-infrastructure/modules/logs/kusto/cluster.bicep @@ -120,3 +120,4 @@ resource kusto 'Microsoft.Kusto/clusters@2024-04-13' = { output id string = kusto.id output name string = kusto.name +output uri string = kusto.properties.uri diff --git a/dev-infrastructure/modules/logs/kusto/grant-access.bicep b/dev-infrastructure/modules/logs/kusto/grant-access.bicep index 0d3f701ea6a..d9ce736e881 100644 --- a/dev-infrastructure/modules/logs/kusto/grant-access.bicep +++ b/dev-infrastructure/modules/logs/kusto/grant-access.bicep @@ -14,7 +14,7 @@ resource database 'Microsoft.Kusto/clusters/databases@2024-04-13' existing = { resource grantIngest 'Microsoft.Kusto/clusters/databases/principalAssignments@2024-04-13' = [ for id in ingestAccessPrincipalIds: { parent: database - name: 'grant-${guid(id, databaseName)}' + name: 'grant-ingest-${guid(id, databaseName)}' properties: { principalId: id principalType: 'App' @@ -27,7 +27,7 @@ resource grantIngest 'Microsoft.Kusto/clusters/databases/principalAssignments@20 resource grantRead 'Microsoft.Kusto/clusters/databases/principalAssignments@2024-04-13' = [ for id in readAccessPrincipalIds: { parent: database - name: 'grant-${guid(id, databaseName)}' + name: 'grant-viewer-${guid(id, databaseName)}' properties: { principalId: id principalType: 'App' diff --git a/dev-infrastructure/modules/logs/kusto/main.bicep b/dev-infrastructure/modules/logs/kusto/main.bicep index 8deea211c2a..1d5e1652a84 100644 --- a/dev-infrastructure/modules/logs/kusto/main.bicep +++ b/dev-infrastructure/modules/logs/kusto/main.bicep @@ -1,3 +1,5 @@ +import * as res from '../../resource.bicep' + @description('Azure Region Location') param location string = resourceGroup().location @@ -44,15 +46,26 @@ param crossClusterServiceLogsScript string = '' @description('Optional cross-cluster HostedControlPlaneLogs Kusto script content.') @secure() param crossClusterHostedControlPlaneLogsScript string = '' + +@description('Optional: Grafana resource ID for database-level Viewer access') +param grafanaResourceId string = '' + var db = { serviceLogs: serviceLogsDatabase hostedControlPlaneLogs: hostedControlPlaneLogsDatabase } var databases = [db.serviceLogs, db.hostedControlPlaneLogs] +var hasGrafana = grafanaResourceId != '' +var grafanaRef = hasGrafana ? res.grafanaRefFromId(grafanaResourceId) : null var dummyScript = '.create-or-alter function with (docstring = \'dummy function to run last and to remove permission\') dummyFunction() {print \'dummy\'}' +resource grafana 'Microsoft.Dashboard/grafana@2024-10-01' existing = if (hasGrafana) { + name: grafanaRef!.name + scope: resourceGroup(grafanaRef!.resourceGroup.subscriptionId, grafanaRef!.resourceGroup.name) +} + var allServiceLogsTablesKQL = { backendLogs: loadTextContent('tables/backendLogs.kql') containerlogs: loadTextContent('tables/containerLogs.kql') @@ -194,7 +207,18 @@ module crossClusterHostedControlPlaneLogsQueryScript 'script.bicep' = if (deploy ] } -// 6. Remove the caller principal +// 6. Grafana service logs access +module grafanaServiceLogsAccess 'grant-access.bicep' = if (hasGrafana) { + name: 'grafana-serviceLogs-viewer' + params: { + kustoName: kustoName + databaseName: db.serviceLogs + readAccessPrincipalIds: [grafana!.identity.principalId] + } + dependsOn: [serviceLogsTables] +} + +// 7. Remove the caller principal // THIS MUST BE THE LAST SCRIPT TO RUN module removePermission 'script.bicep' = [ for (database, i) in databases: { @@ -213,9 +237,11 @@ module removePermission 'script.bicep' = [ hostedControlPlaneLogsTables crossClusterServiceLogsQueryScript crossClusterHostedControlPlaneLogsQueryScript + grafanaServiceLogsAccess ] } ] // Outputs mirror original contract output id string = cluster.outputs.id +output kustoUri string = cluster.outputs.uri diff --git a/dev-infrastructure/region-pipeline.yaml b/dev-infrastructure/region-pipeline.yaml index 8ae3cc80870..a07bdea00d2 100644 --- a/dev-infrastructure/region-pipeline.yaml +++ b/dev-infrastructure/region-pipeline.yaml @@ -60,6 +60,53 @@ resourceGroups: maximumRetryCount: 3 durationBetweenRetries: 1m grafanaName: "{{ .monitoring.grafanaName }}" + grafanaResourceId: + input: + resourceGroup: global + step: output + name: grafanaResourceId + # ADX datasource reconciliation. The triple-AND gate is intentional: + # adxDatasourceEnabled is the feature flag, kusto.manageInstance is + # the per-region "this region owns the geo's Kusto cluster" marker + # (mirrors the per-region allowlist that the deleted geography-level + # step encoded as executionConstraints), and arobit.kusto.enabled is + # what kusto-lookup.bicep gates on for the kustoUri output. Today + # exactly one region per geoShortId has manageInstance=true; if that + # invariant ever changes, the geographies allowlist below would + # need to be region-aware to avoid two regions racing to reconcile + # the same global Grafana datasource. + adx: + enabled: + value: "{{ and .monitoring.adxDatasourceEnabled .kusto.manageInstance .arobit.kusto.enabled }}" + deleteWhenDisabled: true + clusterUrl: + input: + resourceGroup: kusto + step: output + name: kustoUri + defaultDatabase: + configRef: kusto.serviceLogsDatabase + datasourceName: + value: "kusto-{{ .environmentName }}-{{ .azureGeoShortId }}" + geographies: + configRef: monitoring.adxDatasourceGeographies + dataConsistency: strongconsistency + # Retry signature matches any Grafana datasource API mutation in this + # step (POST/PUT/DELETE on /api/datasources*), not only ADX. If + # AzureMonitor reconcile is ever explicitly toggled here, the same + # retry policy applies to those calls too. + automatedRetry: + errorContainsAny: + - "grafana API POST /api/datasources" + - "grafana API PUT /api/datasources/" + - "grafana API DELETE /api/datasources/name/" + - "failed with status 429" + - "failed with status 500" + - "failed with status 502" + - "failed with status 503" + - "failed with status 504" + maximumRetryCount: 5 + durationBetweenRetries: 2m identityFrom: resourceGroup: global step: output @@ -67,6 +114,10 @@ resourceGroups: dependsOn: - resourceGroup: regional step: output + - resourceGroup: global + step: output + - resourceGroup: kusto + step: output - name: kusto resourceGroup: '{{ .kusto.rg }}' subscription: '{{ .global.subscription.key }}' diff --git a/dev-infrastructure/templates/kusto.bicep b/dev-infrastructure/templates/kusto.bicep index f3a5a90c10a..a78dd75cb39 100644 --- a/dev-infrastructure/templates/kusto.bicep +++ b/dev-infrastructure/templates/kusto.bicep @@ -44,6 +44,10 @@ param crossClusterServiceLogsScript string = '' @description('Optional cross-cluster HostedControlPlaneLogs Kusto script content.') @secure() param crossClusterHostedControlPlaneLogsScript string = '' + +@description('Optional: Grafana resource ID for database-level Viewer access') +param grafanaResourceId string = '' + module kusto '../modules/logs/kusto/main.bicep' = if (manageInstance) { name: 'kusto-${location}' params: { @@ -62,5 +66,8 @@ module kusto '../modules/logs/kusto/main.bicep' = if (manageInstance) { enableAutoScale: enableAutoScale crossClusterServiceLogsScript: crossClusterServiceLogsScript crossClusterHostedControlPlaneLogsScript: crossClusterHostedControlPlaneLogsScript + grafanaResourceId: grafanaResourceId } } + +output kustoUri string = manageInstance ? kusto!.outputs.kustoUri : '' diff --git a/docs/monitoring.md b/docs/monitoring.md index 3d653042d92..5b0d4d25e9b 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -90,6 +90,47 @@ A single **Azure Managed Grafana** instance is deployed globally and configured - Region-agnostic dashboard experience - Consolidated alerting and monitoring workflows +### ADX / Kusto datasources + +Managed Grafana can also expose Azure Data Explorer datasources for ARO-HCP Kusto clusters. These datasources follow the same single-workspace-per-environment model as the Azure Monitor datasources above. + +- **Provisioning scope**: Region pipeline, gated per-region by `kusto.manageInstance` (one region per `geoShortId` owns the cluster) +- **Datasource name**: `kusto--` (for example `kusto-int-uk`) +- **Target database**: `ServiceLogs` +- **Authentication**: Grafana workspace system-assigned managed identity +- **RBAC**: Database-level `Viewer` on `ServiceLogs` only + +Provisioning is controlled by: + +- `monitoring.adxDatasourceEnabled` +- `monitoring.adxDatasourceGeographies` + +When `adxDatasourceEnabled` is `true`, the region pipeline's existing `add-grafana-datasource` step uses the typed `GrafanaDatasources` action to create or update the datasource. The step runs in every region the regional pipeline touches, but the runtime gate `adxDatasourceEnabled && kusto.manageInstance && arobit.kusto.enabled` limits actual reconciliation to the region that owns the geography's managed Kusto cluster. `clusterUrl` is sourced from the regional `kusto-lookup.bicep` output, which `existing`s the cluster the geography pipeline already created. When `adxDatasourceGeographies` is empty, all managed Kusto geographies in the environment are included. When it is set, only the listed geography short IDs are allowed. Matching is case-insensitive and ignores surrounding whitespace around comma-separated entries. The same `grafanactl` reconcile path evaluates the allowlist during EV2 rollout and local templatize runs. + +After rollout, validate at least one datasource during the environment bake window with: + +1. Grafana UI +2. Data sources +3. `kusto--` +4. **Save & Test** + +### Kusto datasource lifecycle and teardown + +`adxDatasourceEnabled: false` disables the ADX desired state. The typed datasource reconcile step still runs and deletes the named datasource when deletion-on-disable is enabled, so disabling ADX or removing a geography from `adxDatasourceGeographies` cleans up stale Grafana datasource configuration. + +The regular geography pipeline still fails closed when ADX is desired but no `kustoUri` is available. That prevents accidental deletion caused by transient deployment or output issues during normal rollout or provisioning retries. + +If a datasource needs to be removed outside the desired-state rollout path, delete it explicitly: + +```bash +az grafana data-source delete \ + --name "${GRAFANA_NAME}" \ + --resource-group "${GRAFANA_RG}" \ + --data-source "kusto--" +``` + +Datasource deletion is intentionally separate from normal Kusto cleanup so decommissioning Kusto infrastructure cannot remove shared Grafana configuration by accident. + ### Regional Azure Monitor Workspace Each region contains **two Azure Monitor Workspaces (AMW)**: diff --git a/tooling/templatize/pkg/pipeline/grafana_datasources.go b/tooling/templatize/pkg/pipeline/grafana_datasources.go index 35df564ad9a..2427fee67db 100644 --- a/tooling/templatize/pkg/pipeline/grafana_datasources.go +++ b/tooling/templatize/pkg/pipeline/grafana_datasources.go @@ -17,18 +17,73 @@ package pipeline import ( "context" "fmt" + "strconv" + "strings" + configtypes "github.com/Azure/ARO-Tools/config/types" "github.com/Azure/ARO-Tools/pipelines/graph" "github.com/Azure/ARO-Tools/pipelines/types" "github.com/Azure/ARO-Tools/tools/grafanactl/cmd/modify" ) -func runGrafanaDatasourcesStep(_ graph.Identifier, step *types.GrafanaDatasourcesStep, ctx context.Context, options *StepRunOptions, executionTarget ExecutionTarget, _ *ExecutionState) error { +type resolvedGrafanaADXOptions struct { + Enabled bool + DeleteWhenDisabled bool + ClusterURL string + DefaultDatabase string + DatasourceName string + Geographies string + CurrentGeography string + DataConsistency string +} + +func runGrafanaDatasourcesStep(id graph.Identifier, step *types.GrafanaDatasourcesStep, ctx context.Context, options *StepRunOptions, executionTarget ExecutionTarget, state *ExecutionState) error { opts := modify.DefaultAddDatasourceOptions() opts.GrafanaName = step.GrafanaName opts.SubscriptionID = executionTarget.GetSubscriptionID() opts.ResourceGroup = executionTarget.GetResourceGroup() + if err := func() error { + state.RLock() + defer state.RUnlock() + + if step.GrafanaResourceID != nil { + grafanaResourceID, ok, err := resolveGrafanaDatasourceValue(id.ServiceGroup, "grafanaResourceId", *step.GrafanaResourceID, options.Configuration, state.Outputs) + if err != nil { + return fmt.Errorf("failed to resolve grafanaResourceId: %w", err) + } + if ok { + resolved, err := valueAsString("grafanaResourceId", grafanaResourceID) + if err != nil { + return err + } + opts.GrafanaResourceID = resolved + } + } + + if step.AzureMonitor != nil && step.AzureMonitor.Enabled != nil { + opts.AzureMonitorEnabled = *step.AzureMonitor.Enabled + } + + if step.ADX != nil { + adx, err := resolveGrafanaADXOptions(id.ServiceGroup, step.ADX, options.Configuration, state.Outputs) + if err != nil { + return err + } + opts.ADXEnabled = adx.Enabled + opts.ADXDeleteWhenDisabled = adx.DeleteWhenDisabled + opts.ADXClusterURL = adx.ClusterURL + opts.ADXDefaultDatabase = adx.DefaultDatabase + opts.ADXDatasourceName = adx.DatasourceName + opts.ADXGeographies = adx.Geographies + opts.ADXCurrentGeography = adx.CurrentGeography + opts.ADXDataConsistency = adx.DataConsistency + } + return nil + }(); err != nil { + return err + } + validated, err := opts.Validate(ctx) if err != nil { return fmt.Errorf("validation failed: %w", err) @@ -45,3 +100,112 @@ func runGrafanaDatasourcesStep(_ graph.Identifier, step *types.GrafanaDatasource return nil } + +func resolveGrafanaADXOptions(serviceGroup string, adx *types.GrafanaADXDatasource, cfg configtypes.Configuration, outputs Outputs) (*resolvedGrafanaADXOptions, error) { + enabled, err := resolveOptionalBool(serviceGroup, "adx.enabled", adx.Enabled, cfg, outputs) + if err != nil { + return nil, err + } + geographies, err := resolveOptionalString(serviceGroup, "adx.geographies", adx.Geographies, cfg, outputs) + if err != nil { + return nil, err + } + currentGeography, err := resolveCurrentGeography(cfg) + if err != nil { + return nil, err + } + + resolved := &resolvedGrafanaADXOptions{ + Enabled: enabled, + DeleteWhenDisabled: adx.DeleteWhenDisabled, + Geographies: geographies, + CurrentGeography: currentGeography, + DataConsistency: adx.DataConsistency, + } + + resolved.DatasourceName, err = resolveOptionalString(serviceGroup, "adx.datasourceName", adx.DatasourceName, cfg, outputs) + if err != nil { + return nil, err + } + resolved.ClusterURL, err = resolveOptionalString(serviceGroup, "adx.clusterUrl", adx.ClusterURL, cfg, outputs) + if err != nil { + return nil, err + } + + resolved.DefaultDatabase, err = resolveOptionalString(serviceGroup, "adx.defaultDatabase", adx.DefaultDatabase, cfg, outputs) + if err != nil { + return nil, err + } + + return resolved, nil +} + +func resolveOptionalBool(serviceGroup, name string, value types.Value, cfg configtypes.Configuration, outputs Outputs) (bool, error) { + raw, ok, err := resolveGrafanaDatasourceValue(serviceGroup, name, value, cfg, outputs) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + return valueAsBool(name, raw) +} + +func resolveOptionalString(serviceGroup, name string, value types.Value, cfg configtypes.Configuration, outputs Outputs) (string, error) { + raw, ok, err := resolveGrafanaDatasourceValue(serviceGroup, name, value, cfg, outputs) + if err != nil { + return "", err + } + if !ok { + return "", nil + } + return valueAsString(name, raw) +} + +func resolveGrafanaDatasourceValue(serviceGroup, name string, value types.Value, cfg configtypes.Configuration, outputs Outputs) (any, bool, error) { + if value.Input == nil && value.ConfigRef == "" && value.Value == nil { + return nil, false, nil + } + + values, err := getInputValues(serviceGroup, []types.Variable{{Name: name, Value: value}}, cfg, outputs) + if err != nil { + return nil, false, err + } + return values[name], true, nil +} + +func resolveCurrentGeography(cfg configtypes.Configuration) (string, error) { + currentRaw, err := cfg.GetByPath("azureGeoShortId") + if err != nil { + return "", fmt.Errorf("failed to lookup current geography short ID: %w", err) + } + currentGeography, err := valueAsString("azureGeoShortId", currentRaw) + if err != nil { + return "", err + } + return currentGeography, nil +} + +func valueAsBool(name string, value any) (bool, error) { + switch v := value.(type) { + case bool: + return v, nil + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return false, fmt.Errorf("%s must resolve to a boolean, got %q", name, v) + } + return parsed, nil + default: + return false, fmt.Errorf("%s must resolve to a boolean, got %T", name, value) + } +} + +func valueAsString(name string, value any) (string, error) { + switch v := value.(type) { + case string: + return strings.TrimSpace(v), nil + default: + return "", fmt.Errorf("%s must resolve to a string, got %T", name, value) + } +} diff --git a/tooling/templatize/pkg/pipeline/grafana_datasources_test.go b/tooling/templatize/pkg/pipeline/grafana_datasources_test.go new file mode 100644 index 00000000000..f525913587f --- /dev/null +++ b/tooling/templatize/pkg/pipeline/grafana_datasources_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "strings" + "testing" + + configtypes "github.com/Azure/ARO-Tools/config/types" + "github.com/Azure/ARO-Tools/pipelines/types" + "github.com/Azure/ARO-Tools/tools/grafanactl/cmd/modify" +) + +func testGrafanaDatasourceConfig() configtypes.Configuration { + return configtypes.Configuration{ + "azureGeoShortId": "uk", + "kusto": map[string]any{ + "serviceLogsDatabase": "ServiceLogs", + }, + "monitoring": map[string]any{ + "adxDatasourceGeographies": " UK, eus2 ", + }, + } +} + +func testGrafanaDatasourceOutputs(kustoURI string) Outputs { + return Outputs{ + "Microsoft.Azure.ARO.HCP.Geography": map[string]map[string]Output{ + "kusto-infra": { + "deploy": ArmOutput{ + "kustoUri": map[string]any{ + "type": "String", + "value": kustoURI, + }, + }, + }, + }, + } +} + +func TestResolveGrafanaADXOptionsEnabledAndAllowed(t *testing.T) { + adx := &types.GrafanaADXDatasource{ + Enabled: types.Value{Value: "true"}, + DeleteWhenDisabled: true, + ClusterURL: types.Value{Input: &types.Input{ + StepDependency: types.StepDependency{ResourceGroup: "kusto-infra", Step: "deploy"}, + Name: "kustoUri", + }}, + DefaultDatabase: types.Value{ConfigRef: "kusto.serviceLogsDatabase"}, + DatasourceName: types.Value{Value: "kusto-int-uk"}, + Geographies: types.Value{ConfigRef: "monitoring.adxDatasourceGeographies"}, + DataConsistency: "strongconsistency", + } + + resolved, err := resolveGrafanaADXOptions("Microsoft.Azure.ARO.HCP.Geography", adx, testGrafanaDatasourceConfig(), testGrafanaDatasourceOutputs("https://example.kusto.windows.net")) + if err != nil { + t.Fatalf("resolveGrafanaADXOptions returned error: %v", err) + } + if !resolved.Enabled { + t.Fatal("expected ADX desired state enabled") + } + if !resolved.DeleteWhenDisabled { + t.Fatal("expected deleteWhenDisabled to be preserved") + } + if resolved.ClusterURL != "https://example.kusto.windows.net" { + t.Fatalf("expected cluster URL to resolve, got %q", resolved.ClusterURL) + } + if resolved.DefaultDatabase != "ServiceLogs" { + t.Fatalf("expected default database to resolve, got %q", resolved.DefaultDatabase) + } + if resolved.DatasourceName != "kusto-int-uk" { + t.Fatalf("expected datasource name to resolve, got %q", resolved.DatasourceName) + } + if resolved.Geographies != "UK, eus2" { + t.Fatalf("expected geographies to resolve, got %q", resolved.Geographies) + } + if resolved.CurrentGeography != "uk" { + t.Fatalf("expected current geography to resolve, got %q", resolved.CurrentGeography) + } +} + +func TestResolveGrafanaADXOptionsDisabledWhenGeographyNotAllowed(t *testing.T) { + cfg := testGrafanaDatasourceConfig() + cfg["monitoring"] = map[string]any{ + "adxDatasourceGeographies": "eus2", + } + adx := &types.GrafanaADXDatasource{ + Enabled: types.Value{Value: "true"}, + DeleteWhenDisabled: true, + DatasourceName: types.Value{Value: "kusto-int-uk"}, + Geographies: types.Value{ConfigRef: "monitoring.adxDatasourceGeographies"}, + } + + resolved, err := resolveGrafanaADXOptions("Microsoft.Azure.ARO.HCP.Geography", adx, cfg, nil) + if err != nil { + t.Fatalf("resolveGrafanaADXOptions returned error: %v", err) + } + if resolved.DatasourceName != "kusto-int-uk" { + t.Fatalf("expected datasource name to stay available for delete path, got %q", resolved.DatasourceName) + } + + opts := modify.DefaultAddDatasourceOptions() + opts.SubscriptionID = "subscription-id" + opts.ResourceGroup = "resource-group" + opts.GrafanaName = "grafana-name" + opts.AzureMonitorEnabled = false + opts.ADXEnabled = resolved.Enabled + opts.ADXDeleteWhenDisabled = resolved.DeleteWhenDisabled + opts.ADXDatasourceName = resolved.DatasourceName + opts.ADXGeographies = resolved.Geographies + opts.ADXCurrentGeography = resolved.CurrentGeography + + validated, err := opts.Validate(t.Context()) + if err != nil { + t.Fatalf("Validate returned error: %v", err) + } + if validated.ADXEnabled { + t.Fatal("expected ADX desired state disabled for disallowed geography") + } +} + +func TestResolveGrafanaADXOptionsDisabledByConfigRef(t *testing.T) { + cfg := testGrafanaDatasourceConfig() + cfg["monitoring"] = map[string]any{ + "adxDatasourceEnabled": false, + "adxDatasourceGeographies": "", + } + adx := &types.GrafanaADXDatasource{ + Enabled: types.Value{ConfigRef: "monitoring.adxDatasourceEnabled"}, + DeleteWhenDisabled: true, + DatasourceName: types.Value{Value: "kusto-int-uk"}, + Geographies: types.Value{ConfigRef: "monitoring.adxDatasourceGeographies"}, + } + + resolved, err := resolveGrafanaADXOptions("Microsoft.Azure.ARO.HCP.Geography", adx, cfg, nil) + if err != nil { + t.Fatalf("resolveGrafanaADXOptions returned error: %v", err) + } + if resolved.Enabled { + t.Fatal("expected ADX desired state disabled by configRef") + } + + opts := modify.DefaultAddDatasourceOptions() + opts.SubscriptionID = "subscription-id" + opts.ResourceGroup = "resource-group" + opts.GrafanaName = "grafana-name" + opts.AzureMonitorEnabled = false + opts.ADXEnabled = resolved.Enabled + opts.ADXDeleteWhenDisabled = resolved.DeleteWhenDisabled + opts.ADXDatasourceName = resolved.DatasourceName + opts.ADXGeographies = resolved.Geographies + opts.ADXCurrentGeography = resolved.CurrentGeography + + if _, err := opts.Validate(t.Context()); err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestResolveGrafanaADXOptionsRejectsInvalidGeographyAllowlist(t *testing.T) { + cfg := testGrafanaDatasourceConfig() + cfg["monitoring"] = map[string]any{ + "adxDatasourceGeographies": "uk,!", + } + adx := &types.GrafanaADXDatasource{ + Enabled: types.Value{Value: "true"}, + Geographies: types.Value{ConfigRef: "monitoring.adxDatasourceGeographies"}, + } + + resolved, err := resolveGrafanaADXOptions("Microsoft.Azure.ARO.HCP.Geography", adx, cfg, nil) + if err != nil { + t.Fatalf("resolveGrafanaADXOptions returned error: %v", err) + } + + opts := modify.DefaultAddDatasourceOptions() + opts.SubscriptionID = "subscription-id" + opts.ResourceGroup = "resource-group" + opts.GrafanaName = "grafana-name" + opts.AzureMonitorEnabled = false + opts.ADXEnabled = resolved.Enabled + opts.ADXGeographies = resolved.Geographies + opts.ADXCurrentGeography = resolved.CurrentGeography + + _, err = opts.Validate(t.Context()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "invalid entry") { + t.Fatalf("expected invalid allowlist error, got %v", err) + } +} + +func TestResolveGrafanaADXOptionsFailsClosedOnMissingKustoURI(t *testing.T) { + adx := &types.GrafanaADXDatasource{ + Enabled: types.Value{Value: "true"}, + ClusterURL: types.Value{Input: &types.Input{ + StepDependency: types.StepDependency{ResourceGroup: "kusto-infra", Step: "deploy"}, + Name: "kustoUri", + }}, + DefaultDatabase: types.Value{Value: "ServiceLogs"}, + DatasourceName: types.Value{Value: "kusto-int-uk"}, + } + + resolved, err := resolveGrafanaADXOptions("Microsoft.Azure.ARO.HCP.Geography", adx, testGrafanaDatasourceConfig(), testGrafanaDatasourceOutputs("")) + if err != nil { + t.Fatalf("resolveGrafanaADXOptions returned error: %v", err) + } + + opts := modify.DefaultAddDatasourceOptions() + opts.SubscriptionID = "subscription-id" + opts.ResourceGroup = "resource-group" + opts.GrafanaName = "grafana-name" + opts.AzureMonitorEnabled = false + opts.ADXEnabled = resolved.Enabled + opts.ADXClusterURL = resolved.ClusterURL + opts.ADXDefaultDatabase = resolved.DefaultDatabase + opts.ADXDatasourceName = resolved.DatasourceName + opts.ADXGeographies = resolved.Geographies + opts.ADXCurrentGeography = resolved.CurrentGeography + + _, err = opts.Validate(t.Context()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "cluster URL is required") { + t.Fatalf("expected missing cluster URL error, got %v", err) + } +}