Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion tools/cmdutils/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
)

Expand All @@ -14,9 +15,19 @@ import (
// workload identity token used in the GitHub action as it currently
// does not refresh: https://github.com/Azure/azure-cli/issues/28708
func GetAzureTokenCredentials() (azcore.TokenCredential, error) {
return GetAzureTokenCredentialsForCloud(cloud.AzurePublic)
}

// GetAzureTokenCredentialsForCloud returns an Azure TokenCredential configured for
// a specific Azure cloud. The cloud config determines the AAD authority host used
// when requesting tokens.
func GetAzureTokenCredentialsForCloud(cloudConfig cloud.Configuration) (azcore.TokenCredential, error) {
if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok {
return azidentity.NewAzureCLICredential(nil)
}
Comment on lines +21 to 27
Comment on lines +24 to 27

return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{RequireAzureTokenCredentials: true})
return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{
ClientOptions: azcore.ClientOptions{Cloud: cloudConfig},
RequireAzureTokenCredentials: true,
})
}
10 changes: 10 additions & 0 deletions tools/grafanactl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ All commands require these basic parameters:
- `--output` - Output format: `table` (default) or `json`
- `-v, --verbosity` - Set logging verbosity level (0-10)

For sovereign clouds (e.g. Fairfax ), pass the ARM endpoint and AAD
authority directly. Both flags must be set together; if neither is provided,
the public Azure cloud is used. Each flag accepts either a hostname or a full
`https://` URL — bare hostnames are normalized to URL form automatically.

- `--arm-endpoint` - Azure Resource Manager endpoint (e.g.
`management.usgovcloudapi.net` for Fairfax).
- `--aad-authority` - Microsoft Entra ID authority (e.g.
`login.microsoftonline.us` for Fairfax).

### List Commands

#### List Datasources
Expand Down
98 changes: 93 additions & 5 deletions tools/grafanactl/cmd/base/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ package base

import (
"fmt"
"net/url"
"strings"

"github.com/spf13/cobra"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
azcorearm "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
)

// BaseOptions represents common options used across multiple commands.
Expand All @@ -31,6 +34,15 @@ type BaseOptions struct {
GrafanaResourceID string
OutputFormat string
DryRun bool
ARMEndpoint string
AADAuthority string
}

// CompletedBaseOptions represents base options that have been validated and resolved
type CompletedBaseOptions struct {
// CloudConfig is the resolved Azure SDK cloud configuration derived from the
// ARMEndpoint and AADAuthority flags (or the public cloud defaults when not set)
CloudConfig cloud.Configuration
}

// DefaultBaseOptions returns a new BaseOptions with default values
Expand All @@ -49,22 +61,24 @@ func BindBaseOptions(opts *BaseOptions, cmd *cobra.Command) error {
flags.StringVar(&opts.GrafanaResourceID, "grafana-resource-id", opts.GrafanaResourceID, "Azure Managed Grafana instance resource ID")
flags.StringVar(&opts.OutputFormat, "output", opts.OutputFormat, "Output format: table or json")
flags.BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "Print actions without executing them")
flags.StringVar(&opts.ARMEndpoint, "arm-endpoint", opts.ARMEndpoint, "Azure Resource Manager endpoint for the target cloud. Defaults to the public cloud when unset")
flags.StringVar(&opts.AADAuthority, "aad-authority", opts.AADAuthority, "Microsoft Entra ID (AAD) authority for the target cloud. Defaults to the public cloud when unset")

return nil
}

// ValidateBaseOptions performs validation on the base options
func ValidateBaseOptions(opts *BaseOptions) error {
func ValidateBaseOptions(opts *BaseOptions) (*CompletedBaseOptions, error) {
// Validate required fields

if opts.GrafanaResourceID == "" {
if opts.SubscriptionID == "" || opts.ResourceGroup == "" || opts.GrafanaName == "" {
return fmt.Errorf("subscription ID, resource group, and grafana name are required if grafana resource ID is not provided")
return nil, fmt.Errorf("subscription ID, resource group, and grafana name are required if grafana resource ID is not provided")
}
} else {
resourceID, err := ValidateAzureResourceID(opts.GrafanaResourceID, "Microsoft.Dashboard/grafana")
if err != nil {
return fmt.Errorf("failed to validate grafana resource ID: %w", err)
return nil, fmt.Errorf("failed to validate grafana resource ID: %w", err)
}
opts.SubscriptionID = resourceID.SubscriptionID
opts.ResourceGroup = resourceID.ResourceGroupName
Expand All @@ -73,10 +87,84 @@ func ValidateBaseOptions(opts *BaseOptions) error {

// Validate output format
if opts.OutputFormat != "table" && opts.OutputFormat != "json" {
return fmt.Errorf("output format must be 'table' or 'json', got: %s", opts.OutputFormat)
return nil, fmt.Errorf("output format must be 'table' or 'json', got: %s", opts.OutputFormat)
}

return nil
cloudConfig, err := resolveCloudConfig(opts.ARMEndpoint, opts.AADAuthority)
if err != nil {
return nil, err
}

return &CompletedBaseOptions{CloudConfig: cloudConfig}, nil
}

// resolveCloudConfig builds a cloud.Configuration from the provided ARM endpoint
// and AAD authority. When both inputs are empty, the public cloud configuration is
// returned. When both are set, a custom configuration is built. Each input may be
// a hostname or a full URL (see normalizeEndpoint).
func resolveCloudConfig(armEndpoint, aadAuthority string) (cloud.Configuration, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could add unit test for this

if armEndpoint == "" && aadAuthority == "" {
return cloud.AzurePublic, nil
}
if armEndpoint == "" || aadAuthority == "" {
return cloud.Configuration{}, fmt.Errorf("--arm-endpoint and --aad-authority must both be set, or both unset")
}

normalizedARM, err := normalizeEndpoint(armEndpoint)
if err != nil {
return cloud.Configuration{}, fmt.Errorf("invalid --arm-endpoint %q: %w", armEndpoint, err)
}

normalizedAAD, err := normalizeEndpoint(aadAuthority)
if err != nil {
return cloud.Configuration{}, fmt.Errorf("invalid --aad-authority %q: %w", aadAuthority, err)
}

return cloud.Configuration{
ActiveDirectoryAuthorityHost: normalizedAAD,
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
cloud.ResourceManager: {
Endpoint: normalizedARM,
Audience: normalizedARM,
},
},
}, nil
}

// normalizeEndpoint accepts either a hostname or a full URL and returns a
// URL-form value with the https scheme. EV2 central config typically stores
// endpoint DNS values as hostnames, so we prepend the scheme when it is missing
// to make the input forgiving for callers.
func normalizeEndpoint(endpoint string) (string, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could add unit test for this

endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("endpoint cannot be empty")
}

if !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}

parsed, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("not a valid URL: %w", err)
}
if parsed.Scheme != "https" {
return "", fmt.Errorf("endpoint must use https scheme, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return "", fmt.Errorf("endpoint host cannot be empty")
}

return endpoint, nil
Comment on lines +158 to +159
}

// ARMClientOptions returns an *arm.ClientOptions configured with the resolved
// cloud configuration, suitable for passing to Azure SDK ARM client constructors
func (c *CompletedBaseOptions) ARMClientOptions() *azcorearm.ClientOptions {
return &azcorearm.ClientOptions{
ClientOptions: azcore.ClientOptions{Cloud: c.CloudConfig},
}
}

// ValidateAzureResourceID validates an Azure resource ID and ensures it's an Azure Managed Grafana resource
Expand Down
13 changes: 9 additions & 4 deletions tools/grafanactl/cmd/clean/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type RawCleanDatasourcesOptions struct {
// validatedCleanOptions is a private struct that enforces the options validation pattern.
type validatedCleanDatasourcesOptions struct {
*RawCleanDatasourcesOptions
*base.CompletedBaseOptions
}

// ValidatedCleanOptions represents clean configuration that has passed validation.
Expand Down Expand Up @@ -69,25 +70,29 @@ func BindCleanDatasourcesOptions(opts *RawCleanDatasourcesOptions, cmd *cobra.Co

// Validate performs validation on the raw options
func (o *RawCleanDatasourcesOptions) Validate(ctx context.Context) (*ValidatedCleanDatasourcesOptions, error) {
if err := base.ValidateBaseOptions(o.BaseOptions); err != nil {
completedBase, err := base.ValidateBaseOptions(o.BaseOptions)
if err != nil {
return nil, err
}

return &ValidatedCleanDatasourcesOptions{
validatedCleanDatasourcesOptions: &validatedCleanDatasourcesOptions{
RawCleanDatasourcesOptions: o,
CompletedBaseOptions: completedBase,
},
}, nil
}

// Complete performs final initialization to create fully usable clean options.
func (o *ValidatedCleanDatasourcesOptions) Complete(ctx context.Context) (*CompletedCleanDatasourcesOptions, error) {
cred, err := cmdutils.GetAzureTokenCredentials()
cred, err := cmdutils.GetAzureTokenCredentialsForCloud(o.CloudConfig)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credentials: %w", err)
}

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred)
clientOpts := o.ARMClientOptions()

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create managed Grafana client: %w", err)
}
Expand All @@ -97,7 +102,7 @@ func (o *ValidatedCleanDatasourcesOptions) Complete(ctx context.Context) (*Compl
return nil, fmt.Errorf("failed to create Grafana client: %w", err)
}

monitorWorkspaceClient, err := azure.NewMonitorWorkspaceClient(o.SubscriptionID, cred)
monitorWorkspaceClient, err := azure.NewMonitorWorkspaceClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create managed Prometheus client: %w", err)
}
Expand Down
12 changes: 9 additions & 3 deletions tools/grafanactl/cmd/list/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type RawListDataSourcesOptions struct {
// validatedListDataSourcesOptions is a private struct that enforces the options validation pattern.
type validatedListDataSourcesOptions struct {
*RawListDataSourcesOptions
*base.CompletedBaseOptions
}

// ValidatedListDataSourcesOptions represents list-datasources configuration that has passed validation.
Expand Down Expand Up @@ -63,24 +64,29 @@ func BindListDataSourcesOptions(opts *RawListDataSourcesOptions, cmd *cobra.Comm

// Validate performs validation on the raw options
func (o *RawListDataSourcesOptions) Validate(ctx context.Context) (*ValidatedListDataSourcesOptions, error) {
if err := base.ValidateBaseOptions(o.BaseOptions); err != nil {
completedBase, err := base.ValidateBaseOptions(o.BaseOptions)
if err != nil {
return nil, err
}

return &ValidatedListDataSourcesOptions{
validatedListDataSourcesOptions: &validatedListDataSourcesOptions{
RawListDataSourcesOptions: o,
CompletedBaseOptions: completedBase,
},
}, nil
}

// Complete performs final initialization to create fully usable list-datasources options.
func (o *ValidatedListDataSourcesOptions) Complete(ctx context.Context) (*CompletedListDataSourcesOptions, error) {
cred, err := cmdutils.GetAzureTokenCredentials()
cred, err := cmdutils.GetAzureTokenCredentialsForCloud(o.CloudConfig)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credentials: %w", err)
}
managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred)

clientOpts := o.ARMClientOptions()

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create Managed Grafana client: %w", err)
}
Expand Down
13 changes: 9 additions & 4 deletions tools/grafanactl/cmd/modify/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type RawAddDatasourceOptions struct {
// validatedAddDatasourceOptions is a private struct that enforces the options validation pattern.
type validatedAddDatasourceOptions struct {
*RawAddDatasourceOptions
*base.CompletedBaseOptions
}

// ValidatedAddDatasourceOptions represents add datasource configuration that has passed validation.
Expand Down Expand Up @@ -75,7 +76,8 @@ func BindAddDatasourceOptions(opts *RawAddDatasourceOptions, cmd *cobra.Command)

// Validate performs validation on the raw options
func (o *RawAddDatasourceOptions) Validate(ctx context.Context) (*ValidatedAddDatasourceOptions, error) {
if err := base.ValidateBaseOptions(o.BaseOptions); err != nil {
completedBase, err := base.ValidateBaseOptions(o.BaseOptions)
if err != nil {
return nil, err
}

Expand All @@ -86,23 +88,26 @@ func (o *RawAddDatasourceOptions) Validate(ctx context.Context) (*ValidatedAddDa
TagKey: o.TagKey,
TagValue: o.TagValue,
},
CompletedBaseOptions: completedBase,
},
}, nil
}

// Complete performs final initialization to create fully usable add datasource options.
func (o *ValidatedAddDatasourceOptions) Complete(ctx context.Context) (*CompletedAddDatasourceOptions, error) {
cred, err := cmdutils.GetAzureTokenCredentials()
cred, err := cmdutils.GetAzureTokenCredentialsForCloud(o.CloudConfig)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credentials: %w", err)
}

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred)
clientOpts := o.ARMClientOptions()

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create managed Grafana client: %w", err)
}

monitorWorkspaceClient, err := azure.NewMonitorWorkspaceClient(o.SubscriptionID, cred)
monitorWorkspaceClient, err := azure.NewMonitorWorkspaceClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create monitor workspace client: %w", err)
}
Expand Down
11 changes: 8 additions & 3 deletions tools/grafanactl/cmd/sync/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type RawSyncDashboardsOptions struct {
// validatedSyncDashboardsOptions is a private struct that enforces the options validation pattern.
type validatedSyncDashboardsOptions struct {
*RawSyncDashboardsOptions
*base.CompletedBaseOptions
}

// ValidatedSyncDashboardsOptions represents sync configuration that has passed validation.
Expand Down Expand Up @@ -78,7 +79,8 @@ func BindSyncDashboardsOptions(opts *RawSyncDashboardsOptions, cmd *cobra.Comman

// Validate performs validation on the raw options
func (o *RawSyncDashboardsOptions) Validate(ctx context.Context) (*ValidatedSyncDashboardsOptions, error) {
if err := base.ValidateBaseOptions(o.BaseOptions); err != nil {
completedBase, err := base.ValidateBaseOptions(o.BaseOptions)
if err != nil {
return nil, err
}

Expand All @@ -96,6 +98,7 @@ func (o *RawSyncDashboardsOptions) Validate(ctx context.Context) (*ValidatedSync
return &ValidatedSyncDashboardsOptions{
validatedSyncDashboardsOptions: &validatedSyncDashboardsOptions{
RawSyncDashboardsOptions: o,
CompletedBaseOptions: completedBase,
},
}, nil
}
Expand All @@ -107,12 +110,14 @@ func (o *ValidatedSyncDashboardsOptions) Complete(ctx context.Context) (*Complet
return nil, fmt.Errorf("failed to load config: %w", err)
}

cred, err := cmdutils.GetAzureTokenCredentials()
cred, err := cmdutils.GetAzureTokenCredentialsForCloud(o.CloudConfig)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credentials: %w", err)
}

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred)
clientOpts := o.ARMClientOptions()

managedGrafanaClient, err := azure.NewManagedGrafanaClient(o.SubscriptionID, cred, clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create managed Grafana client: %w", err)
}
Expand Down
9 changes: 6 additions & 3 deletions tools/grafanactl/internal/azure/grafana.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/go-logr/logr"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dashboard/armdashboard/v2"
)

Expand All @@ -32,9 +33,11 @@ type ManagedGrafanaClient struct {
client *armdashboard.GrafanaClient
}

// NewManagedGrafanaClient creates a new ManagedGrafanaClient with the provided credentials
func NewManagedGrafanaClient(subscriptionID string, cred azcore.TokenCredential) (*ManagedGrafanaClient, error) {
grafanaClient, err := armdashboard.NewGrafanaClient(subscriptionID, cred, nil)
// NewManagedGrafanaClient creates a new ManagedGrafanaClient with the provided credentials.
// The clientOptions parameter allows callers to specify cloud-specific configuration
// (e.g. cloud.AzureGovernment for Fairfax). Pass nil to use the default (public cloud).
func NewManagedGrafanaClient(subscriptionID string, cred azcore.TokenCredential, clientOptions *arm.ClientOptions) (*ManagedGrafanaClient, error) {
grafanaClient, err := armdashboard.NewGrafanaClient(subscriptionID, cred, clientOptions)
if err != nil {
return nil, fmt.Errorf("failed to create Azure Monitor Workspaces client: %w", err)
}
Expand Down
Loading
Loading