go.d: add Azure AD auth for mssql, postgres, and sql collectors (#21905)
Co-authored-by: ilyam8 <ilya@netdata.cloud>
Costa Tsaousis committed
Mar 9, 2026 at 20:40 UTC
448588476ce6292c9def0fc391e1280fcf27d8e5
41 files changed
+2261
-28
src/go/go.mod
+7
@@ -68,6 +68,8 @@ require (
68
)
69
70
require (
71
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
72
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
73
github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0
74
github.com/cespare/xxhash/v2 v2.3.0
75
github.com/docker/go-units v0.5.0
@@ -80,8 +82,10 @@ require (
82
require (
83
dario.cat/mergo v1.0.1 // indirect
84
filippo.io/edwards25519 v1.1.1 // indirect
85
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
86
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
87
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
88
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
89
github.com/Masterminds/goutils v1.1.1 // indirect
90
github.com/Masterminds/semver/v3 v3.3.0 // indirect
91
github.com/Microsoft/go-winio v0.6.1 // indirect
@@ -104,6 +108,7 @@ require (
108
github.com/go-openapi/jsonreference v0.21.0 // indirect
109
github.com/go-openapi/swag v0.23.0 // indirect
110
github.com/gogo/protobuf v1.3.2 // indirect
111
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
112
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
113
github.com/golang-sql/sqlexp v0.1.0 // indirect
114
github.com/golang/protobuf v1.5.4 // indirect
@@ -124,6 +129,7 @@ require (
129
github.com/json-iterator/go v1.1.12 // indirect
130
github.com/kamstrup/intmap v0.5.2 // indirect
131
github.com/klauspost/compress v1.17.11 // indirect
132
+ github.com/kylelemons/godebug v1.1.0 // indirect
133
github.com/likexian/gokit v0.25.16 // indirect
134
github.com/mailru/easyjson v0.9.0 // indirect
135
github.com/mdlayher/genetlink v1.3.2 // indirect
@@ -141,6 +147,7 @@ require (
147
github.com/opencontainers/go-digest v1.0.0 // indirect
148
github.com/opencontainers/image-spec v1.0.2 // indirect
149
github.com/opentracing/opentracing-go v1.1.0 // indirect
150
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
151
github.com/pkg/errors v0.9.1 // indirect
152
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
153
github.com/prometheus/client_model v0.6.2 // indirect
src/go/go.sum
+7
@@ -13,6 +13,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEB
13
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
14
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
15
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
16
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
17
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
18
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
19
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
20
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
@@ -23,6 +25,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
25
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
26
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
27
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
28
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
29
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
30
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
31
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
32
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -229,6 +233,8 @@ github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk
233
github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4=
234
github.com/kanocz/fcgi_client v0.0.0-20210113082628-fff85c8adfb7 h1:W0fAsQ7bC1db4k9O2X6yZvatz/0c/ISyxhmNnc6arZA=
235
github.com/kanocz/fcgi_client v0.0.0-20210113082628-fff85c8adfb7/go.mod h1:dHpIS7C6YjFguh5vo9QBVEojDoL3vh3v6oEho2HtNyA=
236
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
237
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
238
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
239
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
240
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
@@ -482,6 +488,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
488
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
489
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
490
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
491
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
492
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
493
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
494
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
src/go/plugin/go.d/collector/mssql/collect.go
+21
-1
@@ -8,6 +8,8 @@ import (
8
"fmt"
9
"strings"
10
"time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
13
)
14
15
// noLatencySentinel is the value SQL Server returns when no latency data is available
@@ -56,7 +58,12 @@ func (c *Collector) collect() (map[string]int64, error) {
58
}
59
60
func (c *Collector) openConnection() (*sql.DB, error) {
59
- db, err := sql.Open("sqlserver", c.DSN)
61
+ driverName, dsn, err := c.resolveConnectionParams()
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+
66
+ db, err := sql.Open(driverName, dsn)
67
if err != nil {
68
return nil, fmt.Errorf("error opening connection: %v", err)
69
}
@@ -76,6 +83,19 @@ func (c *Collector) openConnection() (*sql.DB, error) {
83
return db, nil
84
}
85
86
+func (c *Collector) resolveConnectionParams() (string, string, error) {
87
+ driverName := sqladapter.MSSQLDriver(c.CloudAuth)
88
+ dsn := c.DSN
89
+ if c.CloudAuth.IsEnabled() {
90
+ var err error
91
+ dsn, err = sqladapter.BuildMSSQLAzureADDSN(c.DSN, c.CloudAuth)
92
+ if err != nil {
93
+ return "", "", fmt.Errorf("error preparing cloud auth SQL Server DSN: %v", err)
94
+ }
95
+ }
96
+ return driverName, dsn, nil
97
+}
98
+
99
func (c *Collector) queryVersion() (string, error) {
100
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
101
defer cancel()
src/go/plugin/go.d/collector/mssql/collector.go
+6
@@ -13,8 +13,10 @@ import (
13
14
"github.com/netdata/netdata/go/plugins/pkg/confopt"
15
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
16
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
17
18
_ "github.com/microsoft/go-mssqldb"
19
+ _ "github.com/microsoft/go-mssqldb/azuread"
20
)
21
22
//go:embed "config_schema.json"
@@ -62,6 +64,7 @@ type Config struct {
64
UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
65
DSN string `yaml:"dsn" json:"dsn"`
66
Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
67
+ CloudAuth cloudauth.Config `yaml:"cloud_auth" json:"cloud_auth"`
68
Functions FunctionsConfig `yaml:"functions,omitempty" json:"functions"`
69
}
70
@@ -168,6 +171,9 @@ func (c *Collector) Init(context.Context) error {
171
if c.DSN == "" {
172
return errors.New("config: dsn not set")
173
}
174
+ if err := c.CloudAuth.Validate(); err != nil {
175
+ return err
176
+ }
177
c.Debugf("using DSN [%s]", c.DSN)
178
179
c.funcRouter = newFuncRouter(c)
src/go/plugin/go.d/collector/mssql/config_schema.json
+102
-1
@@ -13,10 +13,89 @@
13
},
14
"dsn": {
15
"title": "DSN",
16
- "description": "Microsoft SQL Server [Data Source Name](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn).",
16
+ "description": "Microsoft SQL Server [Data Source Name](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn). When `cloud_auth.provider` is `azure_ad`, use URL format with `sqlserver://` scheme.",
17
"type": "string",
18
"default": "sqlserver://localhost:1433"
19
},
20
+ "cloud_auth": {
21
+ "title": "Cloud auth",
22
+ "description": "Optional cloud authentication settings for Azure SQL.",
23
+ "type": "object",
24
+ "properties": {
25
+ "provider": {
26
+ "title": "Provider",
27
+ "description": "Cloud auth provider. Use `none` to disable cloud authentication.",
28
+ "type": "string",
29
+ "enum": [
30
+ "none",
31
+ "azure_ad"
32
+ ],
33
+ "default": "none"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "provider": {
38
+ "oneOf": [
39
+ {
40
+ "properties": {
41
+ "provider": {
42
+ "const": "none"
43
+ }
44
+ }
45
+ },
46
+ {
47
+ "properties": {
48
+ "provider": {
49
+ "const": "azure_ad"
50
+ },
51
+ "azure_ad": {
52
+ "title": "Azure AD",
53
+ "description": "Microsoft Entra (Azure AD) settings used when provider is `azure_ad`.",
54
+ "type": "object",
55
+ "properties": {
56
+ "mode": {
57
+ "title": "Mode",
58
+ "description": "Azure AD credential mode.",
59
+ "type": "string",
60
+ "enum": [
61
+ "service_principal",
62
+ "managed_identity",
63
+ "default"
64
+ ],
65
+ "default": "default"
66
+ },
67
+ "tenant_id": {
68
+ "title": "Tenant ID",
69
+ "description": "Azure tenant ID. Required for service_principal mode.",
70
+ "type": "string"
71
+ },
72
+ "client_id": {
73
+ "title": "Client ID",
74
+ "description": "Service principal client ID for service_principal mode.",
75
+ "type": "string"
76
+ },
77
+ "client_secret": {
78
+ "title": "Client Secret",
79
+ "description": "Service principal client secret for service_principal mode.",
80
+ "type": "string",
81
+ "sensitive": true
82
+ },
83
+ "managed_identity_client_id": {
84
+ "title": "Managed Identity Client ID",
85
+ "description": "Optional client ID of a user-assigned managed identity.",
86
+ "type": "string"
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "required": [
92
+ "azure_ad"
93
+ ]
94
+ }
95
+ ]
96
+ }
97
+ }
98
+ },
99
"vnode": {
100
"title": "Virtual Node",
101
"description": "Associates this data collection job with a [Virtual Node](https://learn.netdata.cloud/docs/netdata-agent/configuration/organize-systems-metrics-and-alerts#virtual-nodes).",
@@ -149,6 +228,22 @@
228
"timeout": {
229
"ui:help": "Accepts decimals for sub-second granularity (e.g., 0.5 for 500ms)."
230
},
231
+ "cloud_auth": {
232
+ "provider": {
233
+ "ui:widget": "radio",
234
+ "ui:options": {
235
+ "inline": true
236
+ }
237
+ },
238
+ "azure_ad": {
239
+ "mode": {
240
+ "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
241
+ },
242
+ "client_secret": {
243
+ "ui:widget": "password"
244
+ }
245
+ }
246
+ },
247
"functions": {
248
"top_queries": {
249
"disabled": {
@@ -190,6 +285,12 @@
285
"vnode"
286
]
287
},
288
+ {
289
+ "title": "Cloud Auth",
290
+ "fields": [
291
+ "cloud_auth"
292
+ ]
293
+ },
294
{
295
"title": "Functions",
296
"fields": [
src/go/plugin/go.d/collector/mssql/integrations/microsoft_sql_server.md
+44
-2
@@ -586,7 +586,13 @@ The following options can be defined globally: update_every, autodetection_retry
586
|:------|:-----|:------------|:--------|:---------:|
587
| **Collection** | update_every | Data collection interval (seconds). | 10 | no |
588
| | autodetection_retry | Autodetection retry interval (seconds). Set 0 to disable. | 0 | no |
589
-| **Target** | dsn | SQL Server DSN (Data Source Name). See [DSN syntax](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn). | sqlserver://localhost:1433 | yes |
589
+| **Target** | dsn | SQL Server DSN (Data Source Name). See [DSN syntax](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn). When `azure_ad.enabled` is true, use URL format with `sqlserver://` scheme. | sqlserver://localhost:1433 | yes |
590
+| | azure_ad.enabled | Enable Microsoft Entra (Azure AD) authentication for Azure SQL. | no | no |
591
+| | azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
592
+| | azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
593
+| | azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
594
+| | azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
595
+| | azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
596
| | timeout | Query timeout (seconds). | 5 | no |
597
| **Functions** | functions.top_queries.disabled | Disable the [top-queries](#top-queries) function. | no | no |
598
| | functions.top_queries.timeout | Query timeout for top-queries function (seconds). Uses collector timeout if not set. | | no |
@@ -698,6 +704,43 @@ jobs:
704
```
705
</details>
706
707
+###### Azure SQL with service principal
708
+
709
+Use Microsoft Entra service principal authentication for Azure SQL.
710
+
711
+<details open><summary>Config</summary>
712
+
713
+```yaml
714
+jobs:
715
+ - name: azure_sql_sp
716
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=mydb"
717
+ azure_ad:
718
+ enabled: true
719
+ mode: service_principal
720
+ tenant_id: "00000000-0000-0000-0000-000000000000"
721
+ client_id: "11111111-1111-1111-1111-111111111111"
722
+ client_secret: "super-secret-value"
723
+
724
+```
725
+</details>
726
+
727
+###### Azure SQL with managed identity
728
+
729
+Use managed identity authentication (system-assigned by default).
730
+
731
+<details open><summary>Config</summary>
732
+
733
+```yaml
734
+jobs:
735
+ - name: azure_sql_mi
736
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=mydb"
737
+ azure_ad:
738
+ enabled: true
739
+ mode: managed_identity
740
+
741
+```
742
+</details>
743
+
744
###### Multi-instance
745
746
> **Note**: When you define multiple jobs, their names must be unique.
@@ -831,4 +874,3 @@ The monitoring user needs VIEW SERVER STATE permission.
874
Grant it with: `GRANT VIEW SERVER STATE TO netdata_user;`
875
876
834
-
src/go/plugin/go.d/collector/mssql/metadata.yaml
+54
-1
@@ -127,10 +127,40 @@ modules:
127
group: Collection
128
129
- name: dsn
130
- description: "SQL Server DSN (Data Source Name). See [DSN syntax](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn)."
130
+ description: "SQL Server DSN (Data Source Name). See [DSN syntax](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn). When `cloud_auth.provider` is `azure_ad`, use URL format with `sqlserver://` scheme."
131
default_value: "sqlserver://localhost:1433"
132
required: true
133
group: Target
134
+ - name: cloud_auth.provider
135
+ description: Cloud auth provider (`none` or `azure_ad`).
136
+ default_value: none
137
+ required: false
138
+ group: Cloud Auth
139
+ - name: cloud_auth.azure_ad.mode
140
+ description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
141
+ default_value: default
142
+ required: false
143
+ group: Cloud Auth/Azure
144
+ - name: cloud_auth.azure_ad.tenant_id
145
+ description: Azure tenant ID. Required for `service_principal` mode.
146
+ default_value: ""
147
+ required: false
148
+ group: Cloud Auth/Azure
149
+ - name: cloud_auth.azure_ad.client_id
150
+ description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
151
+ default_value: ""
152
+ required: false
153
+ group: Cloud Auth/Azure
154
+ - name: cloud_auth.azure_ad.client_secret
155
+ description: Azure client secret for `service_principal` mode.
156
+ default_value: ""
157
+ required: false
158
+ group: Cloud Auth/Azure
159
+ - name: cloud_auth.azure_ad.managed_identity_client_id
160
+ description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
161
+ default_value: ""
162
+ required: false
163
+ group: Cloud Auth/Azure
164
- name: timeout
165
description: Query timeout (seconds).
166
default_value: 5
@@ -231,6 +261,29 @@ modules:
261
jobs:
262
- name: remote
263
dsn: "sqlserver://netdata_user:password@192.168.1.100:1433"
264
+ - name: Azure SQL with service principal
265
+ description: Use Microsoft Entra service principal authentication for Azure SQL.
266
+ config: |
267
+ jobs:
268
+ - name: azure_sql_sp
269
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=mydb"
270
+ cloud_auth:
271
+ provider: azure_ad
272
+ azure_ad:
273
+ mode: service_principal
274
+ tenant_id: "00000000-0000-0000-0000-000000000000"
275
+ client_id: "11111111-1111-1111-1111-111111111111"
276
+ client_secret: "super-secret-value"
277
+ - name: Azure SQL with managed identity
278
+ description: Use managed identity authentication (system-assigned by default).
279
+ config: |
280
+ jobs:
281
+ - name: azure_sql_mi
282
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=mydb"
283
+ cloud_auth:
284
+ provider: azure_ad
285
+ azure_ad:
286
+ mode: managed_identity
287
- name: Multi-instance
288
description: |
289
> **Note**: When you define multiple jobs, their names must be unique.
src/go/plugin/go.d/collector/mssql/mssql_test.go
+40
@@ -6,7 +6,10 @@ import (
6
"context"
7
"testing"
8
9
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
10
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
11
"github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
)
14
15
func TestCollector_Init(t *testing.T) {
@@ -23,6 +26,43 @@ func TestCollector_Init_EmptyDSN(t *testing.T) {
26
assert.Error(t, c.Init(context.Background()))
27
}
28
29
+func TestCollector_Init_InvalidAzureADConfig(t *testing.T) {
30
+ c := New()
31
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
32
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
33
+ Mode: cloudauth.AzureADAuthModeServicePrincipal,
34
+ ClientID: "client-id",
35
+ TenantID: "tenant-id",
36
+ }
37
+ // Missing client_secret.
38
+
39
+ assert.Error(t, c.Init(context.Background()))
40
+}
41
+
42
+func TestCollector_openConnection_AzureADRequiresURLDSN(t *testing.T) {
43
+ c := New()
44
+ c.DSN = "server=localhost;database=master"
45
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
46
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
47
+
48
+ db, err := c.openConnection()
49
+ assert.Nil(t, db)
50
+ assert.ErrorContains(t, err, "error preparing cloud auth SQL Server DSN")
51
+}
52
+
53
+func TestCollector_resolveConnectionParams_AzureADRewritesDSNAndDriver(t *testing.T) {
54
+ c := New()
55
+ c.DSN = "sqlserver://localhost:1433?database=master"
56
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
57
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
58
+
59
+ driverName, dsn, err := c.resolveConnectionParams()
60
+ require.NoError(t, err)
61
+
62
+ assert.Equal(t, sqladapter.MSSQLAzureDriverName, driverName)
63
+ assert.Contains(t, dsn, "fedauth=ActiveDirectoryDefault")
64
+}
65
+
66
func TestCollector_Configuration(t *testing.T) {
67
c := New()
68
src/go/plugin/go.d/collector/postgres/azure_ad_test.go
new
+79
@@ -0,0 +1,79 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package postgres
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
12
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
13
+ "github.com/jackc/pgx/v5"
14
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
15
+ "github.com/stretchr/testify/assert"
16
+ "github.com/stretchr/testify/require"
17
+)
18
+
19
+type fakeAADTokenCredential struct {
20
+ getToken func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error)
21
+}
22
+
23
+func (f fakeAADTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
24
+ return f.getToken(ctx, opts)
25
+}
26
+
27
+func TestCollector_openAzureADConnection_NoTokenProvider(t *testing.T) {
28
+ c := New()
29
+ cfg, err := pgx.ParseConfig("postgres://netdata@127.0.0.1:5432/postgres")
30
+ require.NoError(t, err)
31
+
32
+ db, err := c.openAzureADConnection(cfg, "Postgres database")
33
+ assert.Nil(t, db)
34
+ assert.ErrorContains(t, err, "cloud auth token provider is not initialized")
35
+}
36
+
37
+func TestCollector_azureADBeforeConnect_SetsPassword(t *testing.T) {
38
+ cred := fakeAADTokenCredential{
39
+ getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
40
+ return azcore.AccessToken{
41
+ Token: "aad-token",
42
+ ExpiresOn: time.Now().Add(30 * time.Minute),
43
+ }, nil
44
+ },
45
+ }
46
+ provider, err := cloudauth.NewTokenProvider(cred, []string{"scope"}, time.Minute)
47
+ require.NoError(t, err)
48
+
49
+ c := New()
50
+ c.azureTokenProvider = provider
51
+
52
+ cfg, err := pgx.ParseConfig("postgres://netdata@127.0.0.1:5432/postgres")
53
+ require.NoError(t, err)
54
+
55
+ err = c.azureADBeforeConnect(context.Background(), cfg)
56
+ require.NoError(t, err)
57
+ assert.Equal(t, "aad-token", cfg.Password)
58
+}
59
+
60
+func TestCollector_azureADBeforeConnect_ProviderError(t *testing.T) {
61
+ cred := fakeAADTokenCredential{
62
+ getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
63
+ return azcore.AccessToken{}, errors.New("token failure")
64
+ },
65
+ }
66
+ provider, err := cloudauth.NewTokenProvider(cred, []string{"scope"}, time.Minute)
67
+ require.NoError(t, err)
68
+
69
+ c := New()
70
+ c.azureTokenProvider = provider
71
+
72
+ cfg, err := pgx.ParseConfig("postgres://netdata@127.0.0.1:5432/postgres")
73
+ require.NoError(t, err)
74
+ cfg.Password = "old-password"
75
+
76
+ err = c.azureADBeforeConnect(context.Background(), cfg)
77
+ assert.ErrorContains(t, err, "token failure")
78
+ assert.Equal(t, "old-password", cfg.Password)
79
+}
src/go/plugin/go.d/collector/postgres/collect.go
+45
@@ -127,6 +127,14 @@ func (c *Collector) collect() (map[string]int64, error) {
127
}
128
129
func (c *Collector) openPrimaryConnection() (*sql.DB, error) {
130
+ if c.CloudAuth.IsEnabled() {
131
+ cfg, err := pgx.ParseConfig(c.DSN)
132
+ if err != nil {
133
+ return nil, fmt.Errorf("error on parsing DSN [%s]: %v", c.DSN, err)
134
+ }
135
+ return c.openAzureADConnection(cfg, "Postgres database")
136
+ }
137
+
138
db, err := sql.Open("pgx", c.DSN)
139
if err != nil {
140
return nil, fmt.Errorf("error on opening a connection with the Postgres database [%s]: %v", c.DSN, err)
@@ -154,6 +162,12 @@ func (c *Collector) openSecondaryConnection(dbname string) (*sql.DB, string, err
162
}
163
164
cfg.Database = dbname
165
+
166
+ if c.CloudAuth.IsEnabled() {
167
+ db, err := c.openAzureADConnection(cfg, fmt.Sprintf("secondary Postgres database [%s]", dbname))
168
+ return db, "", err
169
+ }
170
+
171
connStr := stdlib.RegisterConnConfig(cfg)
172
173
db, err := sql.Open("pgx", connStr)
@@ -178,6 +192,37 @@ func (c *Collector) openSecondaryConnection(dbname string) (*sql.DB, string, err
192
return db, connStr, nil
193
}
194
195
+func (c *Collector) openAzureADConnection(cfg *pgx.ConnConfig, target string) (*sql.DB, error) {
196
+ if c.azureTokenProvider == nil {
197
+ return nil, fmt.Errorf("cloud auth token provider is not initialized for %s", target)
198
+ }
199
+
200
+ db := stdlib.OpenDB(*cfg, stdlib.OptionBeforeConnect(c.azureADBeforeConnect))
201
+
202
+ db.SetMaxOpenConns(1)
203
+ db.SetMaxIdleConns(1)
204
+ db.SetConnMaxLifetime(10 * time.Minute)
205
+
206
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
207
+ defer cancel()
208
+
209
+ if err := db.PingContext(ctx); err != nil {
210
+ _ = db.Close()
211
+ return nil, fmt.Errorf("error on pinging the %s: %v", target, err)
212
+ }
213
+
214
+ return db, nil
215
+}
216
+
217
+func (c *Collector) azureADBeforeConnect(ctx context.Context, cfg *pgx.ConnConfig) error {
218
+ token, _, err := c.azureTokenProvider.Token(ctx)
219
+ if err != nil {
220
+ return err
221
+ }
222
+ cfg.Password = token
223
+ return nil
224
+}
225
+
226
func (c *Collector) isSuperUser() bool { return c.superUser != nil && *c.superUser }
227
228
func (c *Collector) isPGInRecovery() bool { return c.pgIsInRecovery != nil && *c.pgIsInRecovery }
src/go/plugin/go.d/collector/postgres/collector.go
+24
-2
@@ -11,12 +11,13 @@ import (
11
"sync"
12
"time"
13
14
+ "github.com/jackc/pgx/v5/stdlib"
15
"github.com/netdata/netdata/go/plugins/pkg/confopt"
16
"github.com/netdata/netdata/go/plugins/pkg/matcher"
17
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
18
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
19
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
20
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/oldmetrix"
18
-
19
- "github.com/jackc/pgx/v5/stdlib"
21
)
22
23
//go:embed "config_schema.json"
@@ -71,6 +72,7 @@ type Config struct {
72
AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"`
73
DSN string `yaml:"dsn" json:"dsn"`
74
Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout"`
75
+ CloudAuth cloudauth.Config `yaml:"cloud_auth" json:"cloud_auth"`
76
DBSelector string `yaml:"collect_databases_matching,omitempty" json:"collect_databases_matching"`
77
XactTimeHistogram []float64 `yaml:"transaction_time_histogram,omitempty" json:"transaction_time_histogram"`
78
QueryTimeHistogram []float64 `yaml:"query_time_histogram,omitempty" json:"query_time_histogram"`
@@ -130,6 +132,8 @@ type (
132
doSlowTime time.Time
133
doSlowEvery time.Duration
134
135
+ azureTokenProvider *cloudauth.TokenProvider
136
+
137
mx *pgMetrics
138
139
funcRouter *funcRouter
@@ -150,6 +154,24 @@ func (c *Collector) Init(context.Context) error {
154
if err != nil {
155
return fmt.Errorf("config validation: %v", err)
156
}
157
+ if err := c.CloudAuth.Validate(); err != nil {
158
+ return fmt.Errorf("config validation: %v", err)
159
+ }
160
+ if c.CloudAuth.IsEnabled() {
161
+ cred, err := c.CloudAuth.NewCredential()
162
+ if err != nil {
163
+ return fmt.Errorf("config validation: creating cloud auth credential: %v", err)
164
+ }
165
+ provider, err := cloudauth.NewTokenProvider(
166
+ cred,
167
+ []string{sqladapter.AzurePostgreSQLAADScope},
168
+ cloudauth.DefaultTokenRefreshMargin,
169
+ )
170
+ if err != nil {
171
+ return fmt.Errorf("config validation: creating cloud auth token provider: %v", err)
172
+ }
173
+ c.azureTokenProvider = provider
174
+ }
175
176
sr, err := c.initDBSelector()
177
if err != nil {
src/go/plugin/go.d/collector/postgres/collector_test.go
+32
@@ -14,6 +14,7 @@ import (
14
"testing"
15
16
"github.com/netdata/netdata/go/plugins/pkg/matcher"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
18
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
19
20
"github.com/DATA-DOG/go-sqlmock"
@@ -113,6 +114,21 @@ func TestCollector_Init(t *testing.T) {
114
wantFail: true,
115
config: Config{DSN: ""},
116
},
117
+ "Fail on invalid Azure AD configuration": {
118
+ wantFail: true,
119
+ config: Config{
120
+ DSN: "postgresql://netdata@127.0.0.1:5432/postgres",
121
+ CloudAuth: cloudauth.Config{
122
+ Provider: cloudauth.ProviderAzureAD,
123
+ AzureAD: &cloudauth.AzureADAuthConfig{
124
+ Mode: "service_principal",
125
+ TenantID: "tenant-id",
126
+ ClientID: "client-id",
127
+ // Missing client_secret.
128
+ },
129
+ },
130
+ },
131
+ },
132
}
133
134
for name, test := range tests {
@@ -129,6 +145,22 @@ func TestCollector_Init(t *testing.T) {
145
}
146
}
147
148
+func TestCollector_Init_AzureADInitializesTokenProvider(t *testing.T) {
149
+ c := New()
150
+ c.CloudAuth = cloudauth.Config{
151
+ Provider: cloudauth.ProviderAzureAD,
152
+ AzureAD: &cloudauth.AzureADAuthConfig{
153
+ Mode: cloudauth.AzureADAuthModeServicePrincipal,
154
+ TenantID: "tenant-id",
155
+ ClientID: "client-id",
156
+ ClientSecret: "client-secret",
157
+ },
158
+ }
159
+
160
+ require.NoError(t, c.Init(context.Background()))
161
+ assert.NotNil(t, c.azureTokenProvider)
162
+}
163
+
164
func TestCollector_Cleanup(t *testing.T) {
165
166
}
src/go/plugin/go.d/collector/postgres/config_schema.json
+101
@@ -24,6 +24,85 @@
24
"type": "string",
25
"default": "postgres://netdata:password@127.0.0.1:5432/postgres"
26
},
27
+ "cloud_auth": {
28
+ "title": "Cloud auth",
29
+ "description": "Optional cloud authentication settings for Azure Database for PostgreSQL.",
30
+ "type": "object",
31
+ "properties": {
32
+ "provider": {
33
+ "title": "Provider",
34
+ "description": "Cloud auth provider. Use `none` to disable cloud authentication.",
35
+ "type": "string",
36
+ "enum": [
37
+ "none",
38
+ "azure_ad"
39
+ ],
40
+ "default": "none"
41
+ }
42
+ },
43
+ "dependencies": {
44
+ "provider": {
45
+ "oneOf": [
46
+ {
47
+ "properties": {
48
+ "provider": {
49
+ "const": "none"
50
+ }
51
+ }
52
+ },
53
+ {
54
+ "properties": {
55
+ "provider": {
56
+ "const": "azure_ad"
57
+ },
58
+ "azure_ad": {
59
+ "title": "Azure AD",
60
+ "description": "Microsoft Entra (Azure AD) settings used when provider is `azure_ad`.",
61
+ "type": "object",
62
+ "properties": {
63
+ "mode": {
64
+ "title": "Mode",
65
+ "description": "Azure AD credential mode.",
66
+ "type": "string",
67
+ "enum": [
68
+ "service_principal",
69
+ "managed_identity",
70
+ "default"
71
+ ],
72
+ "default": "default"
73
+ },
74
+ "tenant_id": {
75
+ "title": "Tenant ID",
76
+ "description": "Azure tenant ID. Required for service_principal mode.",
77
+ "type": "string"
78
+ },
79
+ "client_id": {
80
+ "title": "Client ID",
81
+ "description": "Service principal client ID for service_principal mode.",
82
+ "type": "string"
83
+ },
84
+ "client_secret": {
85
+ "title": "Client Secret",
86
+ "description": "Service principal client secret for service_principal mode.",
87
+ "type": "string",
88
+ "sensitive": true
89
+ },
90
+ "managed_identity_client_id": {
91
+ "title": "Managed Identity Client ID",
92
+ "description": "Optional client ID of a user-assigned managed identity.",
93
+ "type": "string"
94
+ }
95
+ }
96
+ }
97
+ },
98
+ "required": [
99
+ "azure_ad"
100
+ ]
101
+ }
102
+ ]
103
+ }
104
+ }
105
+ },
106
"timeout": {
107
"title": "Timeout",
108
"description": "Timeout for queries, in seconds.",
@@ -155,6 +234,12 @@
234
"vnode"
235
]
236
},
237
+ {
238
+ "title": "Cloud Auth",
239
+ "fields": [
240
+ "cloud_auth"
241
+ ]
242
+ },
243
{
244
"title": "Database stats",
245
"fields": [
@@ -187,6 +272,22 @@
272
"dsn": {
273
"ui:placeholder": "postgres://username:password@host:port/dbname"
274
},
275
+ "cloud_auth": {
276
+ "provider": {
277
+ "ui:widget": "radio",
278
+ "ui:options": {
279
+ "inline": true
280
+ }
281
+ },
282
+ "azure_ad": {
283
+ "mode": {
284
+ "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
285
+ },
286
+ "client_secret": {
287
+ "ui:widget": "password"
288
+ }
289
+ }
290
+ },
291
"transaction_time_histogram": {
292
"ui:listFlavour": "list"
293
},
src/go/plugin/go.d/collector/postgres/do_query_misc.go
+14
-6
@@ -66,6 +66,17 @@ func (c *Collector) doQuerySettingsMaxLocksHeld() (int64, error) {
66
67
const connErrMax = 3
68
69
+var unregisterConnConfig = stdlib.UnregisterConnConfig
70
+
71
+func closeDBAndUnregisterConnConfig(db *sql.DB, connStr string) {
72
+ if db != nil {
73
+ _ = db.Close()
74
+ }
75
+ if connStr != "" {
76
+ unregisterConnConfig(connStr)
77
+ }
78
+}
79
+
80
func (c *Collector) doQueryQueryableDatabases() error {
81
q := queryQueryableDatabaseList()
82
@@ -105,8 +116,7 @@ func (c *Collector) doQueryQueryableDatabases() error {
116
if err != nil {
117
c.Warning(err)
118
conn.connErrors++
108
- _ = db.Close()
109
- stdlib.UnregisterConnConfig(connStr)
119
+ closeDBAndUnregisterConnConfig(db, connStr)
120
continue
121
}
122
@@ -114,8 +124,7 @@ func (c *Collector) doQueryQueryableDatabases() error {
124
if err != nil {
125
c.Warning(err)
126
conn.connErrors++
117
- _ = db.Close()
118
- stdlib.UnregisterConnConfig(connStr)
127
+ closeDBAndUnregisterConnConfig(db, connStr)
128
continue
129
}
130
@@ -123,8 +132,7 @@ func (c *Collector) doQueryQueryableDatabases() error {
132
c.Warningf("database '%s' has too many user tables(%d/%d)/indexes(%d/%d), skipping it",
133
dbname, tables, c.MaxDBTables, indexes, c.MaxDBIndexes)
134
conn.connErrors = connErrMax
126
- _ = db.Close()
127
- stdlib.UnregisterConnConfig(connStr)
135
+ closeDBAndUnregisterConnConfig(db, connStr)
136
continue
137
}
138
src/go/plugin/go.d/collector/postgres/do_query_misc_test.go
new
+49
@@ -0,0 +1,49 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package postgres
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/DATA-DOG/go-sqlmock"
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+)
12
+
13
+func TestCloseDBAndUnregisterConnConfig_EmptyConnStr(t *testing.T) {
14
+ db, mock, err := sqlmock.New()
15
+ require.NoError(t, err)
16
+ defer func() { _ = db.Close() }()
17
+ mock.ExpectClose()
18
+
19
+ called := false
20
+ original := unregisterConnConfig
21
+ unregisterConnConfig = func(string) {
22
+ called = true
23
+ }
24
+ defer func() { unregisterConnConfig = original }()
25
+
26
+ closeDBAndUnregisterConnConfig(db, "")
27
+
28
+ assert.False(t, called)
29
+ assert.NoError(t, mock.ExpectationsWereMet())
30
+}
31
+
32
+func TestCloseDBAndUnregisterConnConfig_NonEmptyConnStr(t *testing.T) {
33
+ db, mock, err := sqlmock.New()
34
+ require.NoError(t, err)
35
+ defer func() { _ = db.Close() }()
36
+ mock.ExpectClose()
37
+
38
+ calledWith := ""
39
+ original := unregisterConnConfig
40
+ unregisterConnConfig = func(connStr string) {
41
+ calledWith = connStr
42
+ }
43
+ defer func() { unregisterConnConfig = original }()
44
+
45
+ closeDBAndUnregisterConnConfig(db, "registered-conn")
46
+
47
+ assert.Equal(t, "registered-conn", calledWith)
48
+ assert.NoError(t, mock.ExpectationsWereMet())
49
+}
src/go/plugin/go.d/collector/postgres/integrations/postgresql.md
+43
@@ -529,6 +529,12 @@ The following options can be defined globally: update_every, autodetection_retry
529
| **Collection** | update_every | Data collection interval (seconds). | 1 | no |
530
| | autodetection_retry | Autodetection retry interval (seconds). Set 0 to disable. | 0 | no |
531
| **Target** | dsn | Postgres connection string (DSN). See [DSN syntax](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). | postgres://postgres:postgres@127.0.0.1:5432/postgres | yes |
532
+| | azure_ad.enabled | Enable Microsoft Entra (Azure AD) token authentication for Azure Database for PostgreSQL. | no | no |
533
+| | azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
534
+| | azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
535
+| | azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
536
+| | azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
537
+| | azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
538
| | timeout | Query timeout (seconds). | 2 | no |
539
| **Filters** | collect_databases_matching | Database selector. Controls which databases are included. Uses [simple patterns](https://github.com/netdata/netdata/tree/master/src/go/pkg/matcher#simple-patterns-matcher). | | no |
540
| **Limits** | max_db_tables | Maximum number of tables per database to collect metrics for (0 = no limit). | 50 | no |
@@ -617,6 +623,43 @@ jobs:
623
```
624
</details>
625
626
+###### Azure Database for PostgreSQL with service principal
627
+
628
+Use Microsoft Entra service principal authentication.
629
+
630
+<details open><summary>Config</summary>
631
+
632
+```yaml
633
+jobs:
634
+ - name: azure_postgres_sp
635
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
636
+ azure_ad:
637
+ enabled: true
638
+ mode: service_principal
639
+ tenant_id: "00000000-0000-0000-0000-000000000000"
640
+ client_id: "11111111-1111-1111-1111-111111111111"
641
+ client_secret: "super-secret-value"
642
+
643
+```
644
+</details>
645
+
646
+###### Azure Database for PostgreSQL with managed identity
647
+
648
+Use managed identity authentication (system-assigned by default).
649
+
650
+<details open><summary>Config</summary>
651
+
652
+```yaml
653
+jobs:
654
+ - name: azure_postgres_mi
655
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
656
+ azure_ad:
657
+ enabled: true
658
+ mode: managed_identity
659
+
660
+```
661
+</details>
662
+
663
###### Multi-instance
664
665
> **Note**: When you define multiple jobs, their names must be unique.
src/go/plugin/go.d/collector/postgres/metadata.yaml
+53
@@ -97,6 +97,36 @@ modules:
97
default_value: postgres://postgres:postgres@127.0.0.1:5432/postgres
98
required: true
99
group: Target
100
+ - name: cloud_auth.provider
101
+ description: Cloud auth provider (`none` or `azure_ad`).
102
+ default_value: none
103
+ required: false
104
+ group: Cloud Auth
105
+ - name: cloud_auth.azure_ad.mode
106
+ description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
107
+ default_value: default
108
+ required: false
109
+ group: Cloud Auth/Azure
110
+ - name: cloud_auth.azure_ad.tenant_id
111
+ description: Azure tenant ID. Required for `service_principal` mode.
112
+ default_value: ""
113
+ required: false
114
+ group: Cloud Auth/Azure
115
+ - name: cloud_auth.azure_ad.client_id
116
+ description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
117
+ default_value: ""
118
+ required: false
119
+ group: Cloud Auth/Azure
120
+ - name: cloud_auth.azure_ad.client_secret
121
+ description: Azure client secret for `service_principal` mode.
122
+ default_value: ""
123
+ required: false
124
+ group: Cloud Auth/Azure
125
+ - name: cloud_auth.azure_ad.managed_identity_client_id
126
+ description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
127
+ default_value: ""
128
+ required: false
129
+ group: Cloud Auth/Azure
130
- name: timeout
131
description: Query timeout (seconds).
132
default_value: 2
@@ -166,6 +196,29 @@ modules:
196
jobs:
197
- name: local
198
dsn: 'host=/var/run/postgresql port=5433 dbname=postgres user=netdata'
199
+ - name: Azure Database for PostgreSQL with service principal
200
+ description: Use Microsoft Entra service principal authentication.
201
+ config: |
202
+ jobs:
203
+ - name: azure_postgres_sp
204
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
205
+ cloud_auth:
206
+ provider: azure_ad
207
+ azure_ad:
208
+ mode: service_principal
209
+ tenant_id: "00000000-0000-0000-0000-000000000000"
210
+ client_id: "11111111-1111-1111-1111-111111111111"
211
+ client_secret: "super-secret-value"
212
+ - name: Azure Database for PostgreSQL with managed identity
213
+ description: Use managed identity authentication (system-assigned by default).
214
+ config: |
215
+ jobs:
216
+ - name: azure_postgres_mi
217
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
218
+ cloud_auth:
219
+ provider: azure_ad
220
+ azure_ad:
221
+ mode: managed_identity
222
- name: Multi-instance
223
description: |
224
> **Note**: When you define multiple jobs, their names must be unique.
src/go/plugin/go.d/collector/postgres/testdata/config.json
+3
@@ -3,6 +3,9 @@
3
"update_every": 123,
4
"autodetection_retry": 123,
5
"dsn": "ok",
6
+ "cloud_auth": {
7
+ "provider": "none"
8
+ },
9
"timeout": 123.123,
10
"collect_databases_matching": "ok",
11
"transaction_time_histogram": [
src/go/plugin/go.d/collector/postgres/testdata/config.yaml
+2
@@ -2,6 +2,8 @@ vnode: "ok"
2
update_every: 123
3
autodetection_retry: 123
4
dsn: "ok"
5
+cloud_auth:
6
+ provider: none
7
timeout: 123.123
8
collect_databases_matching: "ok"
9
transaction_time_histogram:
src/go/plugin/go.d/collector/sql/azure_ad_test.go
new
+108
@@ -0,0 +1,108 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sql
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
12
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
13
+ "github.com/jackc/pgx/v5"
14
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
15
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
16
+ "github.com/stretchr/testify/assert"
17
+ "github.com/stretchr/testify/require"
18
+)
19
+
20
+type fakeAADTokenCredential struct {
21
+ getToken func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error)
22
+}
23
+
24
+func (f fakeAADTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
25
+ return f.getToken(ctx, opts)
26
+}
27
+
28
+func TestCollector_openConnection_AzureADSQLServerRequiresURLDSN(t *testing.T) {
29
+ c := New()
30
+ c.Driver = "sqlserver"
31
+ c.DSN = "server=localhost;database=master"
32
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
33
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
34
+
35
+ err := c.openConnection(context.Background())
36
+ assert.ErrorContains(t, err, "prepare cloud_auth SQL Server DSN")
37
+ assert.Nil(t, c.db)
38
+}
39
+
40
+func TestCollector_resolveConnectionParams_AzureADSQLServerRewritesDSNAndDriver(t *testing.T) {
41
+ c := New()
42
+ c.Driver = "sqlserver"
43
+ c.DSN = "sqlserver://localhost:1433?database=master"
44
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
45
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
46
+
47
+ driverName, dsn, err := c.resolveConnectionParams()
48
+ require.NoError(t, err)
49
+
50
+ assert.Equal(t, sqladapter.MSSQLAzureDriverName, driverName)
51
+ assert.Contains(t, dsn, "fedauth=ActiveDirectoryDefault")
52
+}
53
+
54
+func TestCollector_openConnection_AzureADPGXWithoutTokenProvider(t *testing.T) {
55
+ c := New()
56
+ c.Driver = "pgx"
57
+ c.DSN = "postgres://netdata@127.0.0.1:5432/postgres"
58
+ c.CloudAuth.Provider = cloudauth.ProviderAzureAD
59
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault}
60
+
61
+ err := c.openConnection(context.Background())
62
+ assert.ErrorContains(t, err, "cloud auth token provider is not initialized for pgx")
63
+ assert.Nil(t, c.db)
64
+}
65
+
66
+func TestCollector_azureADBeforeConnect_SetsPassword(t *testing.T) {
67
+ cred := fakeAADTokenCredential{
68
+ getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
69
+ return azcore.AccessToken{
70
+ Token: "aad-token",
71
+ ExpiresOn: time.Now().Add(30 * time.Minute),
72
+ }, nil
73
+ },
74
+ }
75
+ provider, err := cloudauth.NewTokenProvider(cred, []string{"scope"}, time.Minute)
76
+ require.NoError(t, err)
77
+
78
+ c := New()
79
+ c.azureTokenProvider = provider
80
+
81
+ cfg, err := pgx.ParseConfig("postgres://netdata@127.0.0.1:5432/postgres")
82
+ require.NoError(t, err)
83
+
84
+ err = c.azureADBeforeConnect(context.Background(), cfg)
85
+ require.NoError(t, err)
86
+ assert.Equal(t, "aad-token", cfg.Password)
87
+}
88
+
89
+func TestCollector_azureADBeforeConnect_ProviderError(t *testing.T) {
90
+ cred := fakeAADTokenCredential{
91
+ getToken: func(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
92
+ return azcore.AccessToken{}, errors.New("token failure")
93
+ },
94
+ }
95
+ provider, err := cloudauth.NewTokenProvider(cred, []string{"scope"}, time.Minute)
96
+ require.NoError(t, err)
97
+
98
+ c := New()
99
+ c.azureTokenProvider = provider
100
+
101
+ cfg, err := pgx.ParseConfig("postgres://netdata@127.0.0.1:5432/postgres")
102
+ require.NoError(t, err)
103
+ cfg.Password = "old-password"
104
+
105
+ err = c.azureADBeforeConnect(context.Background(), cfg)
106
+ assert.ErrorContains(t, err, "token failure")
107
+ assert.Equal(t, "old-password", cfg.Password)
108
+}
src/go/plugin/go.d/collector/sql/collect.go
+73
-3
@@ -5,12 +5,16 @@ package sql
5
import (
6
"context"
7
"database/sql"
8
+ "errors"
9
"fmt"
10
"slices"
11
"strconv"
12
"strings"
13
"time"
14
15
+ "github.com/jackc/pgx/v5"
16
+ "github.com/jackc/pgx/v5/stdlib"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
18
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/oldmetrix"
19
)
20
@@ -162,11 +166,68 @@ func (c *Collector) evalStatusWhen(sw *ConfigStatusWhen, value string) bool {
166
}
167
168
func (c *Collector) openConnection(ctx context.Context) error {
165
- db, err := sql.Open(c.Driver, c.DSN)
169
+ if c.CloudAuth.IsEnabled() && c.Driver == "pgx" {
170
+ return c.openPostgresAzureADConnection(ctx)
171
+ }
172
+
173
+ driverName, dsn, err := c.resolveConnectionParams()
174
+ if err != nil {
175
+ return err
176
+ }
177
+
178
+ db, err := sql.Open(driverName, dsn)
179
+ if err != nil {
180
+ return fmt.Errorf("open %s: %w (dsn=%s)", driverName, err, redactDSN(dsn))
181
+ }
182
+
183
+ db.SetConnMaxLifetime(10 * time.Minute)
184
+
185
+ pingCtx := ctx
186
+ cancel := func() {}
187
+ if d := c.Timeout.Duration(); d > 0 {
188
+ pingCtx, cancel = context.WithTimeout(ctx, d)
189
+ }
190
+ defer cancel()
191
+
192
+ if err := db.PingContext(pingCtx); err != nil {
193
+ _ = db.Close()
194
+ return fmt.Errorf("ping %s: %w (dsn=%s)", driverName, err, redactDSN(dsn))
195
+ }
196
+
197
+ c.db = db
198
+ return nil
199
+}
200
+
201
+func (c *Collector) resolveConnectionParams() (string, string, error) {
202
+ driverName := c.Driver
203
+ dsn := c.DSN
204
+
205
+ if c.CloudAuth.IsEnabled() {
206
+ switch c.Driver {
207
+ case "sqlserver", "azuresql":
208
+ var err error
209
+ dsn, err = sqladapter.BuildMSSQLAzureADDSN(c.DSN, c.CloudAuth)
210
+ if err != nil {
211
+ return "", "", fmt.Errorf("prepare cloud_auth SQL Server DSN: %w", err)
212
+ }
213
+ driverName = sqladapter.MSSQLAzureDriverName
214
+ }
215
+ }
216
+
217
+ return driverName, dsn, nil
218
+}
219
+
220
+func (c *Collector) openPostgresAzureADConnection(ctx context.Context) error {
221
+ if c.azureTokenProvider == nil {
222
+ return errors.New("cloud auth token provider is not initialized for pgx")
223
+ }
224
+
225
+ cfg, err := pgx.ParseConfig(c.DSN)
226
if err != nil {
167
- return fmt.Errorf("open %s: %w (dsn=%s)", c.Driver, err, redactDSN(c.DSN))
227
+ return fmt.Errorf("parse pgx DSN: %w", err)
228
}
229
230
+ db := stdlib.OpenDB(*cfg, stdlib.OptionBeforeConnect(c.azureADBeforeConnect))
231
db.SetConnMaxLifetime(10 * time.Minute)
232
233
pingCtx := ctx
@@ -178,13 +239,22 @@ func (c *Collector) openConnection(ctx context.Context) error {
239
240
if err := db.PingContext(pingCtx); err != nil {
241
_ = db.Close()
181
- return fmt.Errorf("ping %s: %w (dsn=%s)", c.Driver, err, redactDSN(c.DSN))
242
+ return fmt.Errorf("ping pgx: %w (dsn=%s)", err, redactDSN(c.DSN))
243
}
244
245
c.db = db
246
return nil
247
}
248
249
+func (c *Collector) azureADBeforeConnect(ctx context.Context, cfg *pgx.ConnConfig) error {
250
+ token, _, err := c.azureTokenProvider.Token(ctx)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ cfg.Password = token
255
+ return nil
256
+}
257
+
258
func (c *Collector) buildMetricChartID(m ConfigMetricBlock, ch ConfigChartConfig, row map[string]string) string {
259
var b strings.Builder
260
b.Grow(128)
src/go/plugin/go.d/collector/sql/collector.go
+19
@@ -12,6 +12,8 @@ import (
12
13
"github.com/netdata/netdata/go/plugins/pkg/confopt"
14
"github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
15
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
16
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
17
)
18
19
//go:embed "config_schema.json"
@@ -52,6 +54,8 @@ type Collector struct {
54
seenCharts map[string]bool
55
56
funcTable *funcTable
57
+
58
+ azureTokenProvider *cloudauth.TokenProvider
59
}
60
61
func (c *Collector) Configuration() any {
@@ -69,6 +73,21 @@ func (c *Collector) Init(context.Context) error {
73
if err := c.validateConfig(); err != nil {
74
return err
75
}
76
+ if c.CloudAuth.IsProvider(cloudauth.ProviderAzureAD) && c.Driver == "pgx" {
77
+ cred, err := c.CloudAuth.NewCredential()
78
+ if err != nil {
79
+ return err
80
+ }
81
+ provider, err := cloudauth.NewTokenProvider(
82
+ cred,
83
+ []string{sqladapter.AzurePostgreSQLAADScope},
84
+ cloudauth.DefaultTokenRefreshMargin,
85
+ )
86
+ if err != nil {
87
+ return err
88
+ }
89
+ c.azureTokenProvider = provider
90
+ }
91
92
c.funcTable = newFuncTable(c)
93
src/go/plugin/go.d/collector/sql/collector_test.go
+38
@@ -6,6 +6,7 @@ import (
6
"testing"
7
8
"github.com/DATA-DOG/go-sqlmock"
9
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
10
"github.com/stretchr/testify/assert"
11
"github.com/stretchr/testify/require"
12
@@ -142,6 +143,43 @@ func TestCollector_Init_ConfigValidation(t *testing.T) {
143
},
144
wantFail: true,
145
},
146
+ "azure_ad with unsupported driver fails": {
147
+ setup: func(c *Collector) {
148
+ c.Driver = "mysql"
149
+ c.DSN = "user:pass@tcp(localhost:3306)/"
150
+ c.FunctionOnly = true
151
+ c.Functions = []ConfigFunction{{ID: "test", Query: "SELECT 1"}}
152
+ c.CloudAuth.Provider = "azure_ad"
153
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
154
+ Mode: "default",
155
+ }
156
+ },
157
+ wantFail: true,
158
+ },
159
+ "azure_ad service principal missing secret fails": {
160
+ setup: func(c *Collector) {
161
+ c.Driver = "pgx"
162
+ c.DSN = "postgres://user@localhost/db"
163
+ c.FunctionOnly = true
164
+ c.Functions = []ConfigFunction{{ID: "test", Query: "SELECT 1"}}
165
+ c.CloudAuth.Provider = "azure_ad"
166
+ c.CloudAuth.AzureAD = &cloudauth.AzureADAuthConfig{
167
+ Mode: "service_principal",
168
+ TenantID: "tenant",
169
+ ClientID: "client",
170
+ }
171
+ },
172
+ wantFail: true,
173
+ },
174
+ "azuresql driver accepted": {
175
+ setup: func(c *Collector) {
176
+ c.Driver = "azuresql"
177
+ c.DSN = "sqlserver://example.database.windows.net?database=master&fedauth=ActiveDirectoryDefault"
178
+ c.FunctionOnly = true
179
+ c.Functions = []ConfigFunction{{ID: "test", Query: "SELECT 1"}}
180
+ },
181
+ wantFail: false,
182
+ },
183
}
184
185
for name, tc := range tests {
src/go/plugin/go.d/collector/sql/config.go
+17
-3
@@ -9,6 +9,7 @@ import (
9
"strings"
10
11
"github.com/netdata/netdata/go/plugins/pkg/confopt"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
13
)
14
15
type Config struct {
@@ -16,9 +17,10 @@ type Config struct {
17
UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
18
AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"`
19
19
- Driver string `yaml:"driver" json:"driver"`
20
- DSN string `yaml:"dsn" json:"dsn"`
21
- Timeout confopt.Duration `yaml:"timeout" json:"timeout"`
20
+ Driver string `yaml:"driver" json:"driver"`
21
+ DSN string `yaml:"dsn" json:"dsn"`
22
+ Timeout confopt.Duration `yaml:"timeout" json:"timeout"`
23
+ CloudAuth cloudauth.Config `yaml:"cloud_auth" json:"cloud_auth"`
24
25
StaticLabels map[string]string `yaml:"static_labels,omitempty" json:"static_labels"`
26
Queries []ConfigQueryDef `yaml:"queries,omitempty" json:"queries"`
@@ -130,6 +132,18 @@ func (c *Collector) validateConfig() error {
132
if c.DSN == "" {
133
errs = append(errs, errors.New("dsn required"))
134
}
135
+ if err := c.CloudAuth.Validate(); err != nil {
136
+ errs = append(errs, err)
137
+ }
138
+ if c.CloudAuth.IsEnabled() {
139
+ switch c.Driver {
140
+ case "pgx", "sqlserver", "azuresql":
141
+ default:
142
+ errs = append(errs, fmt.Errorf("cloud_auth.provider %q is supported only for drivers %q, %q and %q",
143
+ c.CloudAuth.ProviderName(),
144
+ "pgx", "sqlserver", "azuresql"))
145
+ }
146
+ }
147
148
if c.FunctionOnly {
149
if len(c.Metrics) > 0 {
src/go/plugin/go.d/collector/sql/config_schema.json
+104
-2
@@ -20,9 +20,10 @@
20
},
21
"driver": {
22
"title": "Driver",
23
- "description": "SQL driver / database engine to use. Supported values: `mysql` (MySQL/MariaDB), `pgx` (PostgreSQL), `oracle` (OracleDB), `sqlserver` (Microsoft SQL Server).",
23
+ "description": "SQL driver / database engine to use. Supported values: `mysql` (MySQL/MariaDB), `pgx` (PostgreSQL), `oracle` (OracleDB), `sqlserver` (Microsoft SQL Server), `azuresql` (Microsoft SQL Server with Azure AD).",
24
"type": "string",
25
"enum": [
26
+ "azuresql",
27
"mysql",
28
"pgx",
29
"oracle",
@@ -42,6 +43,85 @@
43
"minimum": 0,
44
"default": 5
45
},
46
+ "cloud_auth": {
47
+ "title": "Cloud auth",
48
+ "description": "Optional cloud authentication settings. `azure_ad` is supported when driver is `pgx`, `sqlserver`, or `azuresql`.",
49
+ "type": "object",
50
+ "properties": {
51
+ "provider": {
52
+ "title": "Provider",
53
+ "description": "Cloud auth provider. Use `none` to disable cloud authentication.",
54
+ "type": "string",
55
+ "enum": [
56
+ "none",
57
+ "azure_ad"
58
+ ],
59
+ "default": "none"
60
+ }
61
+ },
62
+ "dependencies": {
63
+ "provider": {
64
+ "oneOf": [
65
+ {
66
+ "properties": {
67
+ "provider": {
68
+ "const": "none"
69
+ }
70
+ }
71
+ },
72
+ {
73
+ "properties": {
74
+ "provider": {
75
+ "const": "azure_ad"
76
+ },
77
+ "azure_ad": {
78
+ "title": "Azure AD",
79
+ "description": "Microsoft Entra (Azure AD) settings used when provider is `azure_ad`.",
80
+ "type": "object",
81
+ "properties": {
82
+ "mode": {
83
+ "title": "Mode",
84
+ "description": "Azure AD credential mode.",
85
+ "type": "string",
86
+ "enum": [
87
+ "service_principal",
88
+ "managed_identity",
89
+ "default"
90
+ ],
91
+ "default": "default"
92
+ },
93
+ "tenant_id": {
94
+ "title": "Tenant ID",
95
+ "description": "Azure tenant ID. Required for service_principal mode.",
96
+ "type": "string"
97
+ },
98
+ "client_id": {
99
+ "title": "Client ID",
100
+ "description": "Service principal client ID for service_principal mode.",
101
+ "type": "string"
102
+ },
103
+ "client_secret": {
104
+ "title": "Client Secret",
105
+ "description": "Service principal client secret for service_principal mode.",
106
+ "type": "string",
107
+ "sensitive": true
108
+ },
109
+ "managed_identity_client_id": {
110
+ "title": "Managed Identity Client ID",
111
+ "description": "Optional client ID of a user-assigned managed identity.",
112
+ "type": "string"
113
+ }
114
+ }
115
+ }
116
+ },
117
+ "required": [
118
+ "azure_ad"
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ },
125
"static_labels": {
126
"title": "Static labels",
127
"description": "Key/value labels automatically added to every chart created by this job (for example `env=prod`, `region=eu-west`).",
@@ -420,6 +500,12 @@
500
"vnode"
501
]
502
},
503
+ {
504
+ "title": "Cloud Auth",
505
+ "fields": [
506
+ "cloud_auth"
507
+ ]
508
+ },
509
{
510
"title": "Metrics",
511
"fields": [
@@ -459,9 +545,25 @@
545
"inline": true
546
}
547
},
548
+ "cloud_auth": {
549
+ "provider": {
550
+ "ui:widget": "radio",
551
+ "ui:options": {
552
+ "inline": true
553
+ }
554
+ },
555
+ "azure_ad": {
556
+ "mode": {
557
+ "ui:help": "Use `service_principal`, `managed_identity`, or `default` credential chain."
558
+ },
559
+ "client_secret": {
560
+ "ui:widget": "password"
561
+ }
562
+ }
563
+ },
564
"dsn": {
565
"ui:placeholder": "Enter the database connection string",
464
- "ui:help": "**DSN format depends on the selected driver.**\n\n### MySQL / MariaDB\nFormat: `user:password@tcp(host:port)/dbname?param1=value1¶mN=valueN`\nExample:\n```\nroot:pass@tcp(127.0.0.1:3306)/\n```\n[MySQL DSN documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name)\n\n### PostgreSQL\nFormats: `postgresql://user:password@host:5432/dbname?param1=value1¶mN=valueN`\nExample:\n```\npostgresql://user:pass@localhost:5432/mydb\n```\n[PostgreSQL DSN documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS)\n\n### Oracle\nFormat: `oracle://username:password@host:port/service?param1=value1¶mN=valueN`\nExample:\n```\noracle://scott:tiger@db.example.com:1521/ORCLPDB1?ssl=off\n```\n\n### SQL Server\nFormat: `sqlserver://username:password@host:port?database=dbname¶m=value`\nExample:\n```\nsqlserver://sa:myStrong(!)Password@localhost:1433?database=master&connection+timeout=30\n```\n[SQL Server driver DSN documentation](https://github.com/denisenkom/go-mssqldb#connection-parameters-and-dsn)"
566
+ "ui:help": "**DSN format depends on the selected driver.**\n\n### MySQL / MariaDB\nFormat: `user:password@tcp(host:port)/dbname?param1=value1¶mN=valueN`\nExample:\n```\nroot:pass@tcp(127.0.0.1:3306)/\n```\n[MySQL DSN documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name)\n\n### PostgreSQL\nFormats: `postgresql://user:password@host:5432/dbname?param1=value1¶mN=valueN`\nExample:\n```\npostgresql://user:pass@localhost:5432/mydb\n```\n[PostgreSQL DSN documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS)\n\n### Oracle\nFormat: `oracle://username:password@host:port/service?param1=value1¶mN=valueN`\nExample:\n```\noracle://scott:tiger@db.example.com:1521/ORCLPDB1?ssl=off\n```\n\n### SQL Server / Azure SQL\nFormat: `sqlserver://username:password@host:port?database=dbname¶m=value`\nExample:\n```\nsqlserver://sa:myStrong(!)Password@localhost:1433?database=master&connection+timeout=30\n```\n[SQL Server driver DSN documentation](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn)\n\nWhen `cloud_auth.provider` is `azure_ad` with SQL Server drivers, keep DSN host/database settings and configure authentication in `cloud_auth.azure_ad`."
567
},
568
"static_labels": {
569
"ui:help": "Optional key/value labels added to every chart created by this job (for example env=prod, role=primary)."
src/go/plugin/go.d/collector/sql/driver.go
+2
@@ -6,10 +6,12 @@ import (
6
_ "github.com/go-sql-driver/mysql"
7
_ "github.com/jackc/pgx/v5/stdlib"
8
_ "github.com/microsoft/go-mssqldb"
9
+ _ "github.com/microsoft/go-mssqldb/azuread"
10
_ "github.com/sijms/go-ora/v2"
11
)
12
13
var supportedDrivers = map[string]bool{
14
+ "azuresql": true,
15
"mysql": true,
16
"oracle": true,
17
"pgx": true,
src/go/plugin/go.d/collector/sql/integrations/sql_databases_generic.md
+88
-4
@@ -192,11 +192,18 @@ allow connections from the Netdata node.
192
193
```yaml
194
# ---------- CONNECTION ----------
195
-driver: <mysql|pgx|oracle|sqlserver> # REQUIRED. SQL driver.
195
+driver: <mysql|pgx|oracle|sqlserver|azuresql> # REQUIRED. SQL driver.
196
dsn: "<connection string>" # REQUIRED. Driver-specific DSN/URL.
197
198
# Optional connection settings
199
timeout: <seconds> # OPTIONAL. Query timeout.
200
+azure_ad: # OPTIONAL. Azure AD auth for pgx/sqlserver/azuresql.
201
+ enabled: <true|false>
202
+ mode: <service_principal|managed_identity|default>
203
+ tenant_id: "<tenant-id>" # REQUIRED for service_principal
204
+ client_id: "<client-id>" # REQUIRED for service_principal
205
+ client_secret: "<client-secret>" # REQUIRED for service_principal
206
+ managed_identity_client_id: "<client-id>" # Optional for user-assigned MI
207
208
# Optional static labels applied to all charts
209
static_labels:
@@ -301,9 +308,15 @@ functions:
308
|:------|:-----|:------------|:--------|:---------:|
309
| **Collection** | update_every | Data collection interval (seconds). | 1 | no |
310
| | autodetection_retry | Autodetection retry interval (seconds). Not used for this collector. Set 0 to disable. | 0 | no |
304
-| **Target** | driver | SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`. | mysql | yes |
305
-| | dsn | Database connection string (DSN). The format depends on the selected driver ( [MySQL](https://github.com/go-sql-driver/mysql#dsn-data-source-name), [PostgreSQL](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS), [MS SQL Server](https://github.com/denisenkom/go-mssqldb#connection-parameters-and-dsn)). | | yes |
306
-| **Connection** | timeout | Query and connection check timeout (seconds). | 5 | no |
311
+| **Target** | driver | SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`, `azuresql`. | mysql | yes |
312
+| | dsn | Database connection string (DSN). The format depends on the selected driver ( [MySQL](https://github.com/go-sql-driver/mysql#dsn-data-source-name), [PostgreSQL](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS), [MS SQL Server](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn)). | | yes |
313
+| **Connection** | azure_ad.enabled | Enable Microsoft Entra (Azure AD) authentication. Supported for `pgx`, `sqlserver`, and `azuresql`. | no | no |
314
+| | azure_ad.mode | Azure AD credential mode (`service_principal`, `managed_identity`, or `default`). | default | no |
315
+| | azure_ad.tenant_id | Azure tenant ID. Required for `service_principal` mode. | | no |
316
+| | azure_ad.client_id | Azure client ID. Required for `service_principal`; optional for user-assigned managed identity. | | no |
317
+| | azure_ad.client_secret | Azure client secret for `service_principal` mode. | | no |
318
+| | azure_ad.managed_identity_client_id | Optional client ID of a user-assigned managed identity (`managed_identity` mode). | | no |
319
+| | timeout | Query and connection check timeout (seconds). | 5 | no |
320
| **Labels** | static_labels | A map of static labels added to every chart created by this job. Useful for tagging charts with environment, region, or role. | {} | no |
321
| **Queries & Metrics** | queries | A list of reusable queries. Metric blocks can reference these via `query_ref` to avoid repeating SQL. See [Configuration Structure](#configuration) for details. | [] | no |
322
| | metrics | A list of metric blocks. Each block defines how a query is executed and how its result is transformed into one or more charts. See [Configuration Structure](#configuration) for details. | [] | no |
@@ -361,6 +374,77 @@ sudo ./edit-config go.d/sql.conf
374
375
##### Examples
376
377
+###### Azure SQL with service principal (azuresql)
378
+
379
+SQL Server query example against Azure SQL using Microsoft Entra service principal authentication.
380
+
381
+
382
+<details open><summary>Config</summary>
383
+
384
+```yaml
385
+jobs:
386
+ - name: azure_sql_connections
387
+ driver: azuresql
388
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=master"
389
+ timeout: 5
390
+ azure_ad:
391
+ enabled: true
392
+ mode: service_principal
393
+ tenant_id: "00000000-0000-0000-0000-000000000000"
394
+ client_id: "11111111-1111-1111-1111-111111111111"
395
+ client_secret: "super-secret-value"
396
+ metrics:
397
+ - id: user_connections
398
+ mode: columns
399
+ query: |
400
+ SELECT COUNT(*) AS connections
401
+ FROM sys.dm_exec_sessions
402
+ WHERE is_user_process = 1;
403
+ charts:
404
+ - title: "Azure SQL user connections"
405
+ context: sql.azure_sql_user_connections
406
+ family: connections
407
+ units: sessions
408
+ dims:
409
+ - name: users
410
+ source: connections
411
+
412
+```
413
+</details>
414
+
415
+###### Azure PostgreSQL with default credential (pgx)
416
+
417
+PostgreSQL query example against Azure Database for PostgreSQL using the default Azure credential chain.
418
+
419
+
420
+<details open><summary>Config</summary>
421
+
422
+```yaml
423
+jobs:
424
+ - name: azure_pg_uptime
425
+ driver: pgx
426
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
427
+ timeout: 5
428
+ azure_ad:
429
+ enabled: true
430
+ mode: default
431
+ metrics:
432
+ - id: uptime
433
+ mode: columns
434
+ query: |
435
+ SELECT EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time())) AS uptime_seconds;
436
+ charts:
437
+ - title: "Azure PostgreSQL uptime"
438
+ context: sql.azure_pg_uptime
439
+ family: uptime
440
+ units: seconds
441
+ dims:
442
+ - name: uptime
443
+ source: uptime_seconds
444
+
445
+```
446
+</details>
447
+
448
###### Columns mode – per-database conflicts (with labels)
449
450
PostgreSQL example that collects database-level conflict counters from
src/go/plugin/go.d/collector/sql/metadata.yaml
+100
-3
@@ -135,11 +135,19 @@ modules:
135
136
```yaml
137
# ---------- CONNECTION ----------
138
- driver: <mysql|pgx|oracle|sqlserver> # REQUIRED. SQL driver.
138
+ driver: <mysql|pgx|oracle|sqlserver|azuresql> # REQUIRED. SQL driver.
139
dsn: "<connection string>" # REQUIRED. Driver-specific DSN/URL.
140
141
# Optional connection settings
142
timeout: <seconds> # OPTIONAL. Query timeout.
143
+ cloud_auth: # OPTIONAL. Cloud auth for pgx/sqlserver/azuresql.
144
+ provider: <none|azure_ad> # OPTIONAL. Default: none.
145
+ azure_ad:
146
+ mode: <service_principal|managed_identity|default>
147
+ tenant_id: "<tenant-id>" # REQUIRED for service_principal
148
+ client_id: "<client-id>" # REQUIRED for service_principal
149
+ client_secret: "<client-secret>" # REQUIRED for service_principal
150
+ managed_identity_client_id: "<client-id>" # Optional for user-assigned MI
151
152
# Optional static labels applied to all charts
153
static_labels:
@@ -251,7 +259,7 @@ modules:
259
260
- name: driver
261
description: >
254
- SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`.
262
+ SQL driver to use. Supported values: `mysql`, `pgx`, `oracle`, `sqlserver`, `azuresql`.
263
default_value: mysql
264
required: true
265
group: Target
@@ -260,10 +268,40 @@ modules:
268
Database connection string (DSN). The format depends on the selected driver (
269
[MySQL](https://github.com/go-sql-driver/mysql#dsn-data-source-name),
270
[PostgreSQL](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS),
263
- [MS SQL Server](https://github.com/denisenkom/go-mssqldb#connection-parameters-and-dsn)).
271
+ [MS SQL Server](https://github.com/microsoft/go-mssqldb#connection-parameters-and-dsn)).
272
default_value: ""
273
required: true
274
group: Target
275
+ - name: cloud_auth.provider
276
+ description: Cloud auth provider (`none` or `azure_ad`). Supported for `pgx`, `sqlserver`, and `azuresql`.
277
+ default_value: none
278
+ required: false
279
+ group: Cloud Auth
280
+ - name: cloud_auth.azure_ad.mode
281
+ description: Azure AD credential mode (`service_principal`, `managed_identity`, or `default`).
282
+ default_value: default
283
+ required: false
284
+ group: Cloud Auth/Azure
285
+ - name: cloud_auth.azure_ad.tenant_id
286
+ description: Azure tenant ID. Required for `service_principal` mode.
287
+ default_value: ""
288
+ required: false
289
+ group: Cloud Auth/Azure
290
+ - name: cloud_auth.azure_ad.client_id
291
+ description: Azure client ID. Required for `service_principal`; optional for user-assigned managed identity.
292
+ default_value: ""
293
+ required: false
294
+ group: Cloud Auth/Azure
295
+ - name: cloud_auth.azure_ad.client_secret
296
+ description: Azure client secret for `service_principal` mode.
297
+ default_value: ""
298
+ required: false
299
+ group: Cloud Auth/Azure
300
+ - name: cloud_auth.azure_ad.managed_identity_client_id
301
+ description: Optional client ID of a user-assigned managed identity (`managed_identity` mode).
302
+ default_value: ""
303
+ required: false
304
+ group: Cloud Auth/Azure
305
306
- name: timeout
307
description: Query and connection check timeout (seconds).
@@ -365,6 +403,65 @@ modules:
403
title: Config
404
enabled: true
405
list:
406
+ - name: Azure SQL with service principal (azuresql)
407
+ description: |
408
+ SQL Server query example against Azure SQL using Microsoft Entra service principal authentication.
409
+ config: |
410
+ jobs:
411
+ - name: azure_sql_connections
412
+ driver: azuresql
413
+ dsn: "sqlserver://my-server.database.windows.net:1433?database=master"
414
+ timeout: 5
415
+ cloud_auth:
416
+ provider: azure_ad
417
+ azure_ad:
418
+ mode: service_principal
419
+ tenant_id: "00000000-0000-0000-0000-000000000000"
420
+ client_id: "11111111-1111-1111-1111-111111111111"
421
+ client_secret: "super-secret-value"
422
+ metrics:
423
+ - id: user_connections
424
+ mode: columns
425
+ query: |
426
+ SELECT COUNT(*) AS connections
427
+ FROM sys.dm_exec_sessions
428
+ WHERE is_user_process = 1;
429
+ charts:
430
+ - title: "Azure SQL user connections"
431
+ context: sql.azure_sql_user_connections
432
+ family: connections
433
+ units: sessions
434
+ dims:
435
+ - name: users
436
+ source: connections
437
+
438
+ - name: Azure PostgreSQL with default credential (pgx)
439
+ description: |
440
+ PostgreSQL query example against Azure Database for PostgreSQL using the default Azure credential chain.
441
+ config: |
442
+ jobs:
443
+ - name: azure_pg_uptime
444
+ driver: pgx
445
+ dsn: 'postgresql://netdata@myserver.postgres.database.azure.com:5432/postgres?sslmode=require'
446
+ timeout: 5
447
+ cloud_auth:
448
+ provider: azure_ad
449
+ azure_ad:
450
+ mode: default
451
+ metrics:
452
+ - id: uptime
453
+ mode: columns
454
+ query: |
455
+ SELECT EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time())) AS uptime_seconds;
456
+ charts:
457
+ - title: "Azure PostgreSQL uptime"
458
+ context: sql.azure_pg_uptime
459
+ family: uptime
460
+ units: seconds
461
+ dims:
462
+ - name: uptime
463
+ source: uptime_seconds
464
+
465
- name: Columns mode – per-database conflicts (with labels)
466
description: |
467
PostgreSQL example that collects database-level conflict counters from
src/go/plugin/go.d/collector/sql/testdata/config.json
+3
@@ -3,6 +3,9 @@
3
"autodetection_retry": 123,
4
"driver": "postgres",
5
"dsn": "host=localhost port=5432 user=netdata dbname=metrics sslmode=disable",
6
+ "cloud_auth": {
7
+ "provider": "none"
8
+ },
9
"timeout": 5,
10
"static_labels": {
11
"env": "prod",
src/go/plugin/go.d/collector/sql/testdata/config.yaml
+2
@@ -3,6 +3,8 @@ autodetection_retry: 123
3
4
driver: "postgres"
5
dsn: "host=localhost port=5432 user=netdata dbname=metrics sslmode=disable"
6
+cloud_auth:
7
+ provider: none
8
timeout: 5
9
10
static_labels:
src/go/plugin/go.d/pkg/cloudauth/azuread_auth_config.go
new
+84
@@ -0,0 +1,84 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+
10
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
11
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
12
+)
13
+
14
+const (
15
+ AzureADAuthModeServicePrincipal = "service_principal"
16
+ AzureADAuthModeManagedIdentity = "managed_identity"
17
+ AzureADAuthModeDefault = "default"
18
+)
19
+
20
+type AzureADAuthConfig struct {
21
+ Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
22
+ TenantID string `yaml:"tenant_id,omitempty" json:"tenant_id,omitempty"`
23
+ ClientID string `yaml:"client_id,omitempty" json:"client_id,omitempty"`
24
+ ClientSecret string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"`
25
+ ManagedIdentityClientID string `yaml:"managed_identity_client_id,omitempty" json:"managed_identity_client_id,omitempty"`
26
+}
27
+
28
+func (c AzureADAuthConfig) NormalizedMode() string {
29
+ mode := strings.TrimSpace(c.Mode)
30
+ if mode == "" {
31
+ return AzureADAuthModeDefault
32
+ }
33
+ return strings.ToLower(mode)
34
+}
35
+
36
+func (c AzureADAuthConfig) Validate() error {
37
+ switch c.NormalizedMode() {
38
+ case AzureADAuthModeServicePrincipal:
39
+ var errs []error
40
+ if strings.TrimSpace(c.TenantID) == "" {
41
+ errs = append(errs, errors.New("cloud_auth.azure_ad.tenant_id is required for service_principal mode"))
42
+ }
43
+ if strings.TrimSpace(c.ClientID) == "" {
44
+ errs = append(errs, errors.New("cloud_auth.azure_ad.client_id is required for service_principal mode"))
45
+ }
46
+ if strings.TrimSpace(c.ClientSecret) == "" {
47
+ errs = append(errs, errors.New("cloud_auth.azure_ad.client_secret is required for service_principal mode"))
48
+ }
49
+ return errors.Join(errs...)
50
+ case AzureADAuthModeManagedIdentity, AzureADAuthModeDefault:
51
+ return nil
52
+ default:
53
+ return fmt.Errorf("cloud_auth.azure_ad.mode %q is invalid: expected one of %q, %q, %q",
54
+ c.Mode, AzureADAuthModeServicePrincipal, AzureADAuthModeManagedIdentity, AzureADAuthModeDefault)
55
+ }
56
+}
57
+
58
+func (c AzureADAuthConfig) NewCredential() (azcore.TokenCredential, error) {
59
+ if err := c.Validate(); err != nil {
60
+ return nil, err
61
+ }
62
+
63
+ switch c.NormalizedMode() {
64
+ case AzureADAuthModeServicePrincipal:
65
+ return azidentity.NewClientSecretCredential(
66
+ strings.TrimSpace(c.TenantID),
67
+ strings.TrimSpace(c.ClientID),
68
+ strings.TrimSpace(c.ClientSecret),
69
+ nil,
70
+ )
71
+ case AzureADAuthModeManagedIdentity:
72
+ if strings.TrimSpace(c.ManagedIdentityClientID) != "" {
73
+ opts := &azidentity.ManagedIdentityCredentialOptions{
74
+ ID: azidentity.ClientID(strings.TrimSpace(c.ManagedIdentityClientID)),
75
+ }
76
+ return azidentity.NewManagedIdentityCredential(opts)
77
+ }
78
+ return azidentity.NewManagedIdentityCredential(nil)
79
+ case AzureADAuthModeDefault:
80
+ return azidentity.NewDefaultAzureCredential(nil)
81
+ default:
82
+ return nil, fmt.Errorf("cloud_auth.azure_ad.mode %q is invalid", c.Mode)
83
+ }
84
+}
src/go/plugin/go.d/pkg/cloudauth/azuread_auth_config_test.go
new
+57
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/require"
9
+)
10
+
11
+func TestAzureADAuthConfigValidate(t *testing.T) {
12
+ tests := map[string]struct {
13
+ cfg AzureADAuthConfig
14
+ wantErr bool
15
+ }{
16
+ "default mode": {
17
+ cfg: AzureADAuthConfig{Mode: AzureADAuthModeDefault},
18
+ },
19
+ "empty mode defaults to default": {
20
+ cfg: AzureADAuthConfig{},
21
+ },
22
+ "managed identity mode": {
23
+ cfg: AzureADAuthConfig{Mode: AzureADAuthModeManagedIdentity},
24
+ },
25
+ "service principal mode": {
26
+ cfg: AzureADAuthConfig{
27
+ Mode: AzureADAuthModeServicePrincipal,
28
+ TenantID: "tenant",
29
+ ClientID: "client",
30
+ ClientSecret: "secret",
31
+ },
32
+ },
33
+ "service principal missing secret": {
34
+ cfg: AzureADAuthConfig{
35
+ Mode: AzureADAuthModeServicePrincipal,
36
+ TenantID: "tenant",
37
+ ClientID: "client",
38
+ },
39
+ wantErr: true,
40
+ },
41
+ "invalid mode": {
42
+ cfg: AzureADAuthConfig{Mode: "invalid_mode"},
43
+ wantErr: true,
44
+ },
45
+ }
46
+
47
+ for name, tc := range tests {
48
+ t.Run(name, func(t *testing.T) {
49
+ err := tc.cfg.Validate()
50
+ if tc.wantErr {
51
+ require.Error(t, err)
52
+ return
53
+ }
54
+ require.NoError(t, err)
55
+ })
56
+ }
57
+}
src/go/plugin/go.d/pkg/cloudauth/config.go
new
+61
@@ -0,0 +1,61 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "errors"
7
+ "fmt"
8
+
9
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
10
+)
11
+
12
+type Config struct {
13
+ Provider Provider `yaml:"provider" json:"provider"`
14
+ AzureAD *AzureADAuthConfig `yaml:"azure_ad,omitempty" json:"azure_ad,omitempty"`
15
+}
16
+
17
+func (c Config) ProviderName() Provider {
18
+ return c.Provider.Normalized()
19
+}
20
+
21
+func (c Config) IsProvider(provider Provider) bool {
22
+ return c.ProviderName() == provider.Normalized()
23
+}
24
+
25
+func (c Config) IsEnabled() bool {
26
+ return c.ProviderName() != ProviderNone
27
+}
28
+
29
+func (c Config) Validate() error {
30
+ switch c.ProviderName() {
31
+ case ProviderNone:
32
+ return nil
33
+ case ProviderAzureAD:
34
+ return c.azureADConfig().Validate()
35
+ default:
36
+ return fmt.Errorf("cloud_auth.provider %q is invalid: expected one of %q, %q",
37
+ c.Provider, ProviderNone, ProviderAzureAD)
38
+ }
39
+}
40
+
41
+func (c Config) NewCredential() (azcore.TokenCredential, error) {
42
+ if err := c.Validate(); err != nil {
43
+ return nil, err
44
+ }
45
+
46
+ switch c.ProviderName() {
47
+ case ProviderAzureAD:
48
+ return c.azureADConfig().NewCredential()
49
+ case ProviderNone:
50
+ return nil, errors.New("cloud_auth is not enabled")
51
+ default:
52
+ return nil, fmt.Errorf("cloud_auth.provider %q is invalid", c.Provider)
53
+ }
54
+}
55
+
56
+func (c Config) azureADConfig() AzureADAuthConfig {
57
+ if c.AzureAD == nil {
58
+ return AzureADAuthConfig{}
59
+ }
60
+ return *c.AzureAD
61
+}
src/go/plugin/go.d/pkg/cloudauth/config_test.go
new
+144
@@ -0,0 +1,144 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "encoding/json"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+ "gopkg.in/yaml.v3"
12
+)
13
+
14
+func TestConfigValidate(t *testing.T) {
15
+ tests := map[string]struct {
16
+ cfg Config
17
+ wantErr bool
18
+ }{
19
+ "provider omitted": {
20
+ cfg: Config{},
21
+ },
22
+ "provider empty": {
23
+ cfg: Config{Provider: ""},
24
+ },
25
+ "provider none": {
26
+ cfg: Config{Provider: ProviderNone},
27
+ },
28
+ "provider none with azure_ad block": {
29
+ cfg: Config{
30
+ Provider: ProviderNone,
31
+ AzureAD: &AzureADAuthConfig{
32
+ Mode: "service_principal",
33
+ },
34
+ },
35
+ },
36
+ "provider azure_ad valid": {
37
+ cfg: Config{
38
+ Provider: ProviderAzureAD,
39
+ AzureAD: &AzureADAuthConfig{
40
+ Mode: AzureADAuthModeServicePrincipal,
41
+ TenantID: "tenant",
42
+ ClientID: "client",
43
+ ClientSecret: "secret",
44
+ },
45
+ },
46
+ },
47
+ "provider azure_ad invalid": {
48
+ cfg: Config{
49
+ Provider: ProviderAzureAD,
50
+ AzureAD: &AzureADAuthConfig{
51
+ Mode: AzureADAuthModeServicePrincipal,
52
+ TenantID: "tenant",
53
+ ClientID: "client",
54
+ },
55
+ },
56
+ wantErr: true,
57
+ },
58
+ "invalid provider": {
59
+ cfg: Config{Provider: "invalid"},
60
+ wantErr: true,
61
+ },
62
+ }
63
+
64
+ for name, tc := range tests {
65
+ t.Run(name, func(t *testing.T) {
66
+ err := tc.cfg.Validate()
67
+ if tc.wantErr {
68
+ require.Error(t, err)
69
+ return
70
+ }
71
+ require.NoError(t, err)
72
+ })
73
+ }
74
+}
75
+
76
+func TestConfigIsEnabled(t *testing.T) {
77
+ assert.False(t, Config{}.IsEnabled())
78
+ assert.False(t, Config{Provider: ""}.IsEnabled())
79
+ assert.False(t, Config{Provider: ProviderNone}.IsEnabled())
80
+ assert.True(t, Config{Provider: ProviderAzureAD}.IsEnabled())
81
+}
82
+
83
+func TestConfigNewCredentialErrors(t *testing.T) {
84
+ t.Run("provider none", func(t *testing.T) {
85
+ _, err := (Config{Provider: ProviderNone}).NewCredential()
86
+ require.Error(t, err)
87
+ assert.ErrorContains(t, err, "cloud_auth is not enabled")
88
+ })
89
+
90
+ t.Run("invalid provider", func(t *testing.T) {
91
+ _, err := (Config{Provider: Provider("invalid")}).NewCredential()
92
+ require.Error(t, err)
93
+ assert.ErrorContains(t, err, `cloud_auth.provider "invalid" is invalid`)
94
+ })
95
+}
96
+
97
+func TestConfigMarshalDisabledOmitsProviderBlocks(t *testing.T) {
98
+ cfg := Config{}
99
+
100
+ jsonData, err := json.Marshal(cfg)
101
+ require.NoError(t, err)
102
+
103
+ var gotJSON map[string]any
104
+ require.NoError(t, json.Unmarshal(jsonData, &gotJSON))
105
+ assert.Equal(t, "none", gotJSON["provider"])
106
+ _, ok := gotJSON["azure_ad"]
107
+ assert.False(t, ok)
108
+
109
+ yamlData, err := yaml.Marshal(cfg)
110
+ require.NoError(t, err)
111
+
112
+ var gotYAML map[string]any
113
+ require.NoError(t, yaml.Unmarshal(yamlData, &gotYAML))
114
+ assert.Equal(t, "none", gotYAML["provider"])
115
+ _, ok = gotYAML["azure_ad"]
116
+ assert.False(t, ok)
117
+}
118
+
119
+func TestConfigMarshalAzureADIncludesBlock(t *testing.T) {
120
+ cfg := Config{
121
+ Provider: ProviderAzureAD,
122
+ AzureAD: &AzureADAuthConfig{
123
+ Mode: AzureADAuthModeDefault,
124
+ },
125
+ }
126
+
127
+ jsonData, err := json.Marshal(cfg)
128
+ require.NoError(t, err)
129
+
130
+ var gotJSON map[string]any
131
+ require.NoError(t, json.Unmarshal(jsonData, &gotJSON))
132
+ assert.Equal(t, "azure_ad", gotJSON["provider"])
133
+ _, ok := gotJSON["azure_ad"]
134
+ assert.True(t, ok)
135
+
136
+ yamlData, err := yaml.Marshal(cfg)
137
+ require.NoError(t, err)
138
+
139
+ var gotYAML map[string]any
140
+ require.NoError(t, yaml.Unmarshal(yamlData, &gotYAML))
141
+ assert.Equal(t, "azure_ad", gotYAML["provider"])
142
+ _, ok = gotYAML["azure_ad"]
143
+ assert.True(t, ok)
144
+}
src/go/plugin/go.d/pkg/cloudauth/provider.go
new
+57
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "encoding/json"
7
+ "strings"
8
+)
9
+
10
+type Provider string
11
+
12
+const (
13
+ ProviderNone Provider = "none"
14
+ ProviderAzureAD Provider = "azure_ad"
15
+)
16
+
17
+func (p Provider) Normalized() Provider {
18
+ return normalizeProviderValue(p)
19
+}
20
+
21
+func (p Provider) MarshalJSON() ([]byte, error) {
22
+ return json.Marshal(p.marshalValue())
23
+}
24
+
25
+func (p *Provider) UnmarshalJSON(data []byte) error {
26
+ var raw string
27
+ if err := json.Unmarshal(data, &raw); err != nil {
28
+ return err
29
+ }
30
+ *p = normalizeProviderValue(Provider(raw))
31
+ return nil
32
+}
33
+
34
+func (p Provider) MarshalYAML() (any, error) {
35
+ return p.marshalValue(), nil
36
+}
37
+
38
+func (p *Provider) UnmarshalYAML(unmarshal func(interface{}) error) error {
39
+ var raw string
40
+ if err := unmarshal(&raw); err != nil {
41
+ return err
42
+ }
43
+ *p = normalizeProviderValue(Provider(raw))
44
+ return nil
45
+}
46
+
47
+func (p Provider) marshalValue() string {
48
+ return string(p.Normalized())
49
+}
50
+
51
+func normalizeProviderValue(provider Provider) Provider {
52
+ p := strings.ToLower(strings.TrimSpace(string(provider)))
53
+ if p == "" {
54
+ return ProviderNone
55
+ }
56
+ return Provider(p)
57
+}
src/go/plugin/go.d/pkg/cloudauth/provider_test.go
new
+70
@@ -0,0 +1,70 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "encoding/json"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+ "gopkg.in/yaml.v3"
12
+)
13
+
14
+func TestProviderUnmarshalJSON_EmptyToNone(t *testing.T) {
15
+ var cfg Config
16
+
17
+ err := json.Unmarshal([]byte(`{"provider":""}`), &cfg)
18
+ require.NoError(t, err)
19
+
20
+ assert.Equal(t, ProviderNone, cfg.Provider)
21
+ assert.Equal(t, ProviderNone, cfg.ProviderName())
22
+}
23
+
24
+func TestProviderUnmarshalYAML_EmptyToNone(t *testing.T) {
25
+ var cfg Config
26
+
27
+ err := yaml.Unmarshal([]byte("provider: \"\""), &cfg)
28
+ require.NoError(t, err)
29
+
30
+ assert.Equal(t, ProviderNone, cfg.Provider)
31
+ assert.Equal(t, ProviderNone, cfg.ProviderName())
32
+}
33
+
34
+func TestProviderUnmarshalJSON_TrimAndCaseToAzureAD(t *testing.T) {
35
+ var cfg Config
36
+
37
+ err := json.Unmarshal([]byte(`{"provider":" AZURE_AD "}`), &cfg)
38
+ require.NoError(t, err)
39
+
40
+ assert.Equal(t, ProviderAzureAD, cfg.Provider)
41
+ assert.Equal(t, ProviderAzureAD, cfg.ProviderName())
42
+}
43
+
44
+func TestProviderUnmarshalJSON_AzureADAliasWithoutUnderscoreIsInvalid(t *testing.T) {
45
+ var cfg Config
46
+
47
+ err := json.Unmarshal([]byte(`{"provider":"AzureAD"}`), &cfg)
48
+ require.NoError(t, err)
49
+
50
+ assert.Equal(t, Provider("azuread"), cfg.Provider)
51
+ assert.Equal(t, Provider("azuread"), cfg.ProviderName())
52
+
53
+ err = cfg.Validate()
54
+ require.Error(t, err)
55
+ assert.ErrorContains(t, err, `cloud_auth.provider "azuread" is invalid`)
56
+}
57
+
58
+func TestProviderMarshalJSON_NoneToNormalized(t *testing.T) {
59
+ data, err := json.Marshal(Config{Provider: ProviderNone})
60
+ require.NoError(t, err)
61
+
62
+ assert.Contains(t, string(data), `"provider":"none"`)
63
+}
64
+
65
+func TestProviderMarshalYAML_NoneToNormalized(t *testing.T) {
66
+ data, err := yaml.Marshal(Config{Provider: ProviderNone})
67
+ require.NoError(t, err)
68
+
69
+ assert.Contains(t, string(data), "provider: none")
70
+}
src/go/plugin/go.d/pkg/cloudauth/sqladapter/mssql.go
new
+84
@@ -0,0 +1,84 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sqladapter
4
+
5
+import (
6
+ "fmt"
7
+ "net/url"
8
+ "strings"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
11
+)
12
+
13
+const (
14
+ MSSQLDriverName = "sqlserver"
15
+ MSSQLAzureDriverName = "azuresql"
16
+
17
+ mssqlFedAuthDefault = "ActiveDirectoryDefault"
18
+ mssqlFedAuthManagedIdentity = "ActiveDirectoryManagedIdentity"
19
+ mssqlFedAuthServicePrincipal = "ActiveDirectoryServicePrincipal"
20
+)
21
+
22
+func MSSQLDriver(cfg cloudauth.Config) string {
23
+ if cfg.IsProvider(cloudauth.ProviderAzureAD) {
24
+ return MSSQLAzureDriverName
25
+ }
26
+ return MSSQLDriverName
27
+}
28
+
29
+func BuildMSSQLAzureADDSN(baseDSN string, cfg cloudauth.Config) (string, error) {
30
+ if err := cfg.Validate(); err != nil {
31
+ return "", err
32
+ }
33
+ if !cfg.IsProvider(cloudauth.ProviderAzureAD) {
34
+ return baseDSN, nil
35
+ }
36
+
37
+ aadCfg := cloudauth.AzureADAuthConfig{}
38
+ if cfg.AzureAD != nil {
39
+ aadCfg = *cfg.AzureAD
40
+ }
41
+
42
+ u, err := url.Parse(baseDSN)
43
+ if err != nil {
44
+ return "", fmt.Errorf("parsing SQL Server DSN: %w", err)
45
+ }
46
+ if !strings.EqualFold(u.Scheme, "sqlserver") {
47
+ return "", fmt.Errorf("cloud_auth.provider %q requires URL DSN with sqlserver scheme, got %q", cloudauth.ProviderAzureAD, u.Scheme)
48
+ }
49
+
50
+ q := u.Query()
51
+ for key := range q {
52
+ switch strings.ToLower(key) {
53
+ case "fedauth", "user id", "password":
54
+ q.Del(key)
55
+ }
56
+ }
57
+
58
+ switch aadCfg.NormalizedMode() {
59
+ case cloudauth.AzureADAuthModeServicePrincipal:
60
+ q.Set("fedauth", mssqlFedAuthServicePrincipal)
61
+ clientID := strings.TrimSpace(aadCfg.ClientID)
62
+ clientSecret := strings.TrimSpace(aadCfg.ClientSecret)
63
+ userID := clientID
64
+ if tenantID := strings.TrimSpace(aadCfg.TenantID); tenantID != "" {
65
+ userID = userID + "@" + tenantID
66
+ }
67
+ u.User = url.UserPassword(userID, clientSecret)
68
+ case cloudauth.AzureADAuthModeManagedIdentity:
69
+ q.Set("fedauth", mssqlFedAuthManagedIdentity)
70
+ u.User = nil
71
+ if id := strings.TrimSpace(aadCfg.ManagedIdentityClientID); id != "" {
72
+ q.Set("user id", id)
73
+ }
74
+ case cloudauth.AzureADAuthModeDefault:
75
+ q.Set("fedauth", mssqlFedAuthDefault)
76
+ u.User = nil
77
+ default:
78
+ return "", fmt.Errorf("unsupported cloud_auth.azure_ad.mode %q", aadCfg.Mode)
79
+ }
80
+
81
+ u.RawQuery = q.Encode()
82
+
83
+ return u.String(), nil
84
+}
src/go/plugin/go.d/pkg/cloudauth/sqladapter/mssql_test.go
new
+132
@@ -0,0 +1,132 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sqladapter
4
+
5
+import (
6
+ "net/url"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/assert"
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
13
+)
14
+
15
+func TestMSSQLDriver(t *testing.T) {
16
+ assert.Equal(t, MSSQLDriverName, MSSQLDriver(cloudauth.Config{}))
17
+ assert.Equal(t, MSSQLAzureDriverName, MSSQLDriver(cloudauth.Config{Provider: cloudauth.ProviderAzureAD}))
18
+}
19
+
20
+func TestBuildMSSQLAzureADDSN(t *testing.T) {
21
+ base := "sqlserver://localhost:1433?database=master"
22
+
23
+ tests := map[string]struct {
24
+ baseDSN string
25
+ cfg cloudauth.Config
26
+ wantErr bool
27
+ validate func(t *testing.T, dsn string, u *url.URL)
28
+ parseDSN bool
29
+ }{
30
+ "provider disabled returns original dsn": {
31
+ baseDSN: base,
32
+ cfg: cloudauth.Config{Provider: cloudauth.ProviderNone},
33
+ validate: func(t *testing.T, dsn string, _ *url.URL) {
34
+ assert.Equal(t, base, dsn)
35
+ },
36
+ },
37
+ "service principal": {
38
+ baseDSN: base,
39
+ cfg: cloudauth.Config{
40
+ Provider: cloudauth.ProviderAzureAD,
41
+ AzureAD: &cloudauth.AzureADAuthConfig{
42
+ Mode: cloudauth.AzureADAuthModeServicePrincipal,
43
+ TenantID: "tenant",
44
+ ClientID: "client",
45
+ ClientSecret: "secret",
46
+ },
47
+ },
48
+ parseDSN: true,
49
+ validate: func(t *testing.T, _ string, u *url.URL) {
50
+ assert.Equal(t, "ActiveDirectoryServicePrincipal", u.Query().Get("fedauth"))
51
+ assert.Equal(t, "client@tenant", u.User.Username())
52
+ pass, ok := u.User.Password()
53
+ require.True(t, ok)
54
+ assert.Equal(t, "secret", pass)
55
+ },
56
+ },
57
+ "managed identity with client id": {
58
+ baseDSN: base,
59
+ cfg: cloudauth.Config{
60
+ Provider: cloudauth.ProviderAzureAD,
61
+ AzureAD: &cloudauth.AzureADAuthConfig{
62
+ Mode: cloudauth.AzureADAuthModeManagedIdentity,
63
+ ManagedIdentityClientID: "mi-client-id",
64
+ },
65
+ },
66
+ parseDSN: true,
67
+ validate: func(t *testing.T, _ string, u *url.URL) {
68
+ assert.Equal(t, "ActiveDirectoryManagedIdentity", u.Query().Get("fedauth"))
69
+ assert.Equal(t, "mi-client-id", u.Query().Get("user id"))
70
+ assert.Nil(t, u.User)
71
+ },
72
+ },
73
+ "default credential": {
74
+ baseDSN: base,
75
+ cfg: cloudauth.Config{
76
+ Provider: cloudauth.ProviderAzureAD,
77
+ AzureAD: &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault},
78
+ },
79
+ parseDSN: true,
80
+ validate: func(t *testing.T, _ string, u *url.URL) {
81
+ assert.Equal(t, "ActiveDirectoryDefault", u.Query().Get("fedauth"))
82
+ assert.Nil(t, u.User)
83
+ },
84
+ },
85
+ "cleans mixed-case stale params": {
86
+ baseDSN: "sqlserver://olduser:oldpass@localhost:1433?database=master&FedAuth=old&User+ID=old&Password=old",
87
+ cfg: cloudauth.Config{
88
+ Provider: cloudauth.ProviderAzureAD,
89
+ AzureAD: &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault},
90
+ },
91
+ parseDSN: true,
92
+ validate: func(t *testing.T, _ string, u *url.URL) {
93
+ assert.Empty(t, u.Query().Get("FedAuth"))
94
+ assert.Empty(t, u.Query().Get("User ID"))
95
+ assert.Empty(t, u.Query().Get("Password"))
96
+ assert.Equal(t, "ActiveDirectoryDefault", u.Query().Get("fedauth"))
97
+ assert.Nil(t, u.User)
98
+ },
99
+ },
100
+ "invalid scheme": {
101
+ baseDSN: "server=localhost;database=master",
102
+ cfg: cloudauth.Config{
103
+ Provider: cloudauth.ProviderAzureAD,
104
+ AzureAD: &cloudauth.AzureADAuthConfig{Mode: cloudauth.AzureADAuthModeDefault},
105
+ },
106
+ wantErr: true,
107
+ },
108
+ }
109
+
110
+ for name, tc := range tests {
111
+ t.Run(name, func(t *testing.T) {
112
+ dsn, err := BuildMSSQLAzureADDSN(tc.baseDSN, tc.cfg)
113
+ if tc.wantErr {
114
+ require.Error(t, err)
115
+ return
116
+ }
117
+
118
+ require.NoError(t, err)
119
+ if tc.validate == nil {
120
+ return
121
+ }
122
+
123
+ var u *url.URL
124
+ if tc.parseDSN {
125
+ parsed, parseErr := url.Parse(dsn)
126
+ require.NoError(t, parseErr)
127
+ u = parsed
128
+ }
129
+ tc.validate(t, dsn, u)
130
+ })
131
+ }
132
+}
src/go/plugin/go.d/pkg/cloudauth/sqladapter/scopes.go
new
+7
@@ -0,0 +1,7 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package sqladapter
4
+
5
+const (
6
+ AzurePostgreSQLAADScope = "https://ossrdbms-aad.database.windows.net/.default"
7
+)
src/go/plugin/go.d/pkg/cloudauth/token_provider.go
new
+82
@@ -0,0 +1,82 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
13
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
14
+)
15
+
16
+const (
17
+ DefaultTokenRefreshMargin = 5 * time.Minute
18
+)
19
+
20
+type TokenProvider struct {
21
+ mu sync.Mutex
22
+ cred azcore.TokenCredential
23
+ scopes []string
24
+ refreshMargin time.Duration
25
+ now func() time.Time
26
+
27
+ cachedToken string
28
+ cachedExpiry time.Time
29
+}
30
+
31
+func NewTokenProvider(cred azcore.TokenCredential, scopes []string, refreshMargin time.Duration) (*TokenProvider, error) {
32
+ if cred == nil {
33
+ return nil, errors.New("token credential is nil")
34
+ }
35
+ if len(scopes) == 0 {
36
+ return nil, errors.New("token scopes are required")
37
+ }
38
+ for _, scope := range scopes {
39
+ if scope == "" {
40
+ return nil, errors.New("token scopes contain an empty value")
41
+ }
42
+ }
43
+ if refreshMargin <= 0 {
44
+ refreshMargin = DefaultTokenRefreshMargin
45
+ }
46
+
47
+ return &TokenProvider{
48
+ cred: cred,
49
+ scopes: scopes,
50
+ refreshMargin: refreshMargin,
51
+ now: time.Now,
52
+ }, nil
53
+}
54
+
55
+func (p *TokenProvider) Token(ctx context.Context) (string, time.Time, error) {
56
+ p.mu.Lock()
57
+ defer p.mu.Unlock()
58
+
59
+ now := p.now()
60
+ if p.cachedToken != "" && now.Add(p.refreshMargin).Before(p.cachedExpiry) {
61
+ return p.cachedToken, p.cachedExpiry, nil
62
+ }
63
+
64
+ token, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: p.scopes})
65
+ if err != nil {
66
+ // Fall back to cached token if it hasn't expired yet
67
+ // Re-evaluate time after refresh attempt, because GetToken may block.
68
+ fallbackNow := p.now()
69
+ if p.cachedToken != "" && fallbackNow.Before(p.cachedExpiry) {
70
+ return p.cachedToken, p.cachedExpiry, nil
71
+ }
72
+ return "", time.Time{}, err
73
+ }
74
+ if token.Token == "" {
75
+ return "", time.Time{}, fmt.Errorf("received empty token for scopes %v", p.scopes)
76
+ }
77
+
78
+ p.cachedToken = token.Token
79
+ p.cachedExpiry = token.ExpiresOn
80
+
81
+ return p.cachedToken, p.cachedExpiry, nil
82
+}
src/go/plugin/go.d/pkg/cloudauth/token_provider_test.go
new
+203
@@ -0,0 +1,203 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cloudauth
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
12
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+)
16
+
17
+type fakeTokenCredential struct {
18
+ getToken func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error)
19
+}
20
+
21
+func (f fakeTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
22
+ return f.getToken(ctx, opts)
23
+}
24
+
25
+func TestNewTokenProviderValidation(t *testing.T) {
26
+ _, err := NewTokenProvider(nil, []string{"scope"}, time.Minute)
27
+ require.Error(t, err)
28
+
29
+ cred := fakeTokenCredential{
30
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
31
+ return azcore.AccessToken{}, nil
32
+ },
33
+ }
34
+ _, err = NewTokenProvider(cred, nil, time.Minute)
35
+ require.Error(t, err)
36
+}
37
+
38
+func TestNewTokenProviderDefaultsRefreshMarginWhenNonPositive(t *testing.T) {
39
+ cred := fakeTokenCredential{
40
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
41
+ return azcore.AccessToken{}, nil
42
+ },
43
+ }
44
+
45
+ p, err := NewTokenProvider(cred, []string{"scope"}, 0)
46
+ require.NoError(t, err)
47
+ assert.Equal(t, DefaultTokenRefreshMargin, p.refreshMargin)
48
+
49
+ p, err = NewTokenProvider(cred, []string{"scope"}, -1*time.Minute)
50
+ require.NoError(t, err)
51
+ assert.Equal(t, DefaultTokenRefreshMargin, p.refreshMargin)
52
+}
53
+
54
+func TestTokenProviderCachesToken(t *testing.T) {
55
+ baseNow := time.Date(2026, time.March, 6, 10, 0, 0, 0, time.UTC)
56
+ calls := 0
57
+ cred := fakeTokenCredential{
58
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
59
+ calls++
60
+ return azcore.AccessToken{
61
+ Token: "token-1",
62
+ ExpiresOn: baseNow.Add(30 * time.Minute),
63
+ }, nil
64
+ },
65
+ }
66
+
67
+ p, err := NewTokenProvider(cred, []string{"scope"}, 5*time.Minute)
68
+ require.NoError(t, err)
69
+ p.now = func() time.Time { return baseNow }
70
+
71
+ token, expiry, err := p.Token(context.Background())
72
+ require.NoError(t, err)
73
+ assert.Equal(t, "token-1", token)
74
+ assert.Equal(t, baseNow.Add(30*time.Minute), expiry)
75
+
76
+ token, _, err = p.Token(context.Background())
77
+ require.NoError(t, err)
78
+ assert.Equal(t, "token-1", token)
79
+ assert.Equal(t, 1, calls)
80
+}
81
+
82
+func TestTokenProviderRefreshesNearExpiry(t *testing.T) {
83
+ baseNow := time.Date(2026, time.March, 6, 10, 0, 0, 0, time.UTC)
84
+ currentNow := baseNow
85
+ calls := 0
86
+
87
+ cred := fakeTokenCredential{
88
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
89
+ calls++
90
+ return azcore.AccessToken{
91
+ Token: "token-" + string(rune('0'+calls)),
92
+ ExpiresOn: currentNow.Add(10 * time.Minute),
93
+ }, nil
94
+ },
95
+ }
96
+
97
+ p, err := NewTokenProvider(cred, []string{"scope"}, 5*time.Minute)
98
+ require.NoError(t, err)
99
+ p.now = func() time.Time { return currentNow }
100
+
101
+ first, _, err := p.Token(context.Background())
102
+ require.NoError(t, err)
103
+
104
+ // Past expiry-refresh boundary: now + margin >= cached expiry.
105
+ currentNow = baseNow.Add(6 * time.Minute)
106
+ second, _, err := p.Token(context.Background())
107
+ require.NoError(t, err)
108
+
109
+ assert.NotEqual(t, first, second)
110
+ assert.Equal(t, 2, calls)
111
+}
112
+
113
+func TestTokenProviderReturnsCredentialError(t *testing.T) {
114
+ cred := fakeTokenCredential{
115
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
116
+ return azcore.AccessToken{}, errors.New("boom")
117
+ },
118
+ }
119
+
120
+ p, err := NewTokenProvider(cred, []string{"scope"}, time.Minute)
121
+ require.NoError(t, err)
122
+
123
+ _, _, err = p.Token(context.Background())
124
+ require.Error(t, err)
125
+}
126
+
127
+func TestTokenProviderFallsBackOnRefreshFailure(t *testing.T) {
128
+ baseNow := time.Date(2026, time.March, 6, 10, 0, 0, 0, time.UTC)
129
+ currentNow := baseNow
130
+ calls := 0
131
+
132
+ cred := fakeTokenCredential{
133
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
134
+ calls++
135
+ if calls == 1 {
136
+ return azcore.AccessToken{
137
+ Token: "good-token",
138
+ ExpiresOn: baseNow.Add(10 * time.Minute),
139
+ }, nil
140
+ }
141
+ return azcore.AccessToken{}, errors.New("transient failure")
142
+ },
143
+ }
144
+
145
+ p, err := NewTokenProvider(cred, []string{"scope"}, 5*time.Minute)
146
+ require.NoError(t, err)
147
+ p.now = func() time.Time { return currentNow }
148
+
149
+ // First call succeeds
150
+ token, _, err := p.Token(context.Background())
151
+ require.NoError(t, err)
152
+ assert.Equal(t, "good-token", token)
153
+
154
+ // Advance into refresh margin but before expiry
155
+ currentNow = baseNow.Add(6 * time.Minute)
156
+ token, _, err = p.Token(context.Background())
157
+ require.NoError(t, err)
158
+ assert.Equal(t, "good-token", token)
159
+
160
+ // Advance past expiry — should error since no valid cache
161
+ currentNow = baseNow.Add(11 * time.Minute)
162
+ _, _, err = p.Token(context.Background())
163
+ require.Error(t, err)
164
+}
165
+
166
+func TestTokenProviderDoesNotFallbackToExpiredTokenAfterSlowRefreshFailure(t *testing.T) {
167
+ baseNow := time.Date(2026, time.March, 6, 10, 0, 0, 0, time.UTC)
168
+ currentNow := baseNow
169
+ calls := 0
170
+
171
+ cred := fakeTokenCredential{
172
+ getToken: func(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
173
+ calls++
174
+ if calls == 1 {
175
+ return azcore.AccessToken{
176
+ Token: "good-token",
177
+ ExpiresOn: baseNow.Add(10 * time.Minute),
178
+ }, nil
179
+ }
180
+
181
+ // Simulate a slow refresh attempt that crosses token expiry before failing.
182
+ currentNow = baseNow.Add(11 * time.Minute)
183
+ return azcore.AccessToken{}, errors.New("slow refresh failure")
184
+ },
185
+ }
186
+
187
+ p, err := NewTokenProvider(cred, []string{"scope"}, 5*time.Minute)
188
+ require.NoError(t, err)
189
+ p.now = func() time.Time { return currentNow }
190
+
191
+ // Seed cache.
192
+ token, _, err := p.Token(context.Background())
193
+ require.NoError(t, err)
194
+ assert.Equal(t, "good-token", token)
195
+
196
+ // Enter refresh window while token is still valid.
197
+ currentNow = baseNow.Add(6 * time.Minute)
198
+
199
+ // Refresh fails after cache has expired in wall-clock time.
200
+ _, _, err = p.Token(context.Background())
201
+ require.Error(t, err)
202
+ assert.Equal(t, 2, calls)
203
+}