@cryptotaxi247 / netdata-1 / commits / 448588476

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&paramN=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&paramN=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&paramN=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&param=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&paramN=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&paramN=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&paramN=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&param=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 +}