Skip to content
Draft
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
36 changes: 29 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ make clean

### Command Structure

grafanactl follows the Cobra command pattern with two main command groups:
grafanactl follows the Cobra command pattern with three main command groups:

1. **config**: Manage configuration contexts for connecting to Grafana instances
1. **auth**: Authenticate with Grafana via OpenID Connect (OIDC)
- `auth login`: Log in using OIDC (Authorization Code + PKCE flow)
- `auth status`: Show current OIDC authentication status

2. **config**: Manage configuration contexts for connecting to Grafana instances
- `config set`: Set configuration values
- `config unset`: Unset configuration values
- `config use-context`: Switch between configured contexts
Expand All @@ -92,7 +96,7 @@ grafanactl follows the Cobra command pattern with two main command groups:
- `config view`: View the current configuration
- `config check`: Validate the configuration

2. **resources**: Manipulate Grafana resources (dashboards, folders, etc.)
3. **resources**: Manipulate Grafana resources (dashboards, folders, etc.)
- `resources get`: Get resources from Grafana
- `resources list`: List resources
- `resources pull`: Pull resources from Grafana to local files
Expand All @@ -106,15 +110,22 @@ grafanactl follows the Cobra command pattern with two main command groups:

**cmd/grafanactl/** - CLI command implementations
- `root/`: Root command setup with logging and flags
- `config/`: Configuration management commands
- `config/`: Configuration management commands and OIDC authentication commands (login, status)
- `resources/`: Resource manipulation commands
- `fail/`: Error handling and detailed error messages
- `io/`: Output formatting and user messages

**internal/auth/** - OIDC authentication
- Authorization Code + PKCE flow with local callback server
- OIDC discovery via `.well-known/openid-configuration`
- Token refresh using `golang.org/x/oauth2`
- Token expiry checking and cache management

**internal/config/** - Configuration management
- Context-based configuration (similar to kubectl contexts)
- Support for multiple Grafana instances (contexts)
- Authentication: basic auth, API tokens
- Authentication: OIDC, basic auth, API tokens
- OIDC token cache stored separately at `$XDG_CACHE_HOME/grafanactl/tokens.yaml`
- Environment variable overrides (GRAFANA_SERVER, GRAFANA_TOKEN, etc.)
- Automatic Stack ID discovery for Grafana Cloud
- TLS configuration support
Expand Down Expand Up @@ -198,6 +209,10 @@ Environment variables can override configuration values:
- `GRAFANA_PASSWORD`: Password for basic auth
- `GRAFANA_ORG_ID`: Organization ID (on-prem)
- `GRAFANA_STACK_ID`: Stack ID (Grafana Cloud)
- `GRAFANA_OIDC_ISSUER`: OIDC provider issuer URL
- `GRAFANA_OIDC_CLIENT_ID`: OIDC client ID
- `GRAFANA_OIDC_CLIENT_SECRET`: OIDC client secret (optional for PKCE)
- `GRAFANA_OIDC_SCOPES`: OIDC scopes (space-separated)

## Testing Patterns

Expand All @@ -206,6 +221,10 @@ Environment variables can override configuration values:
- Resource filtering/selector tests in `internal/resources/*_test.go`
- Test data in `testdata/` directories
- Use `make tests` to run all tests with race detection
- OIDC integration tests require Dex (`docker compose up -d dex`) and use a build tag:
```bash
go test -tags=integration ./internal/auth/
```

## Code Generation

Expand Down Expand Up @@ -491,7 +510,7 @@ The codebase is designed for extension:
2. **New Formats**: Add codec to `internal/format/codec.go`
3. **New Processors**: Implement Processor interface
4. **Custom Handlers**: Add to `internal/server/handlers/`
5. **Authentication Methods**: Extend `internal/config/rest.go`
5. **Authentication Methods**: OIDC tokens are resolved into `APIToken` during config loading (`cmd/grafanactl/config/command.go`), keeping auth call sites in `rest.go`, `client.go`, and `requests.go` unchanged

### Dependencies of Note

Expand All @@ -508,6 +527,7 @@ The codebase is designed for extension:
- `github.com/grafana/grafana-app-sdk/logging`: Structured logging

**Utilities**:
- `golang.org/x/oauth2` v0.30.0: OIDC token exchange and refresh
- `golang.org/x/sync` v0.17.0: errgroup for concurrency
- `github.com/goccy/go-yaml` v1.18.0: YAML codec
- `github.com/fsnotify/fsnotify` v1.9.0: File watching
Expand All @@ -516,7 +536,9 @@ The codebase is designed for extension:

**Credential Management**:
- API tokens stored in config file with 0600 permissions
- Password fields tagged with `datapolicy:"secret"` for redaction
- OIDC tokens cached separately at `$XDG_CACHE_HOME/grafanactl/tokens.yaml` with 0600 permissions
- OIDC provider config (issuer, client ID) stored in main config; tokens never in config
- Password and secret fields tagged with `datapolicy:"secret"` for redaction
- TLS certificate data base64-encoded in config
- Environment variables supported for CI/CD (avoid config files)

Expand Down
164 changes: 164 additions & 0 deletions cmd/grafanactl/config/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package config

import (
"fmt"

"github.com/grafana/grafanactl/cmd/grafanactl/io"
"github.com/grafana/grafanactl/internal/auth"
"github.com/grafana/grafanactl/internal/config"
"github.com/spf13/cobra"
)

// AuthCommand returns the top-level auth command group.
func AuthCommand() *cobra.Command {
configOpts := &Options{}

cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with a Grafana instance",
Long: "Authenticate with a Grafana instance using OpenID Connect (OIDC).",
}

configOpts.BindFlags(cmd.PersistentFlags())

cmd.AddCommand(loginCmd(configOpts))
cmd.AddCommand(statusCmd(configOpts))

return cmd
}

func loginCmd(configOpts *Options) *cobra.Command {
var callbackPort int

cmd := &cobra.Command{
Use: "login",
Args: cobra.NoArgs,
Short: "Log in using OIDC",
Long: `Log in to Grafana using OpenID Connect (OIDC) with the Authorization Code + PKCE flow.

This opens your browser for authentication with the configured OIDC provider.
The resulting tokens are cached separately from the configuration file.

Before running this command, configure OIDC settings for the context:

grafanactl config set contexts.<name>.grafana.oidc.issuer-url https://your-idp.example.com
grafanactl config set contexts.<name>.grafana.oidc.client-id your-client-id`,
Example: "\n\tgrafanactl auth login\n\tgrafanactl auth login --context production\n\tgrafanactl auth login --callback-port 8085",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := configOpts.loadConfigTolerant(cmd.Context())
if err != nil {
return err
}

gCtx := cfg.GetCurrentContext()
if gCtx == nil {
return fmt.Errorf("no current context set")
}

if gCtx.Grafana == nil {
gCtx.Grafana = &config.GrafanaConfig{}
}

if gCtx.Grafana.OIDC == nil {
return fmt.Errorf("OIDC is not configured for context %q\n\nConfigure it with:\n grafanactl config set contexts.%[1]s.grafana.oidc.issuer-url <issuer-url>\n grafanactl config set contexts.%[1]s.grafana.oidc.client-id <client-id>", gCtx.Name)
}

if !gCtx.Grafana.OIDC.IsConfigured() {
return fmt.Errorf("OIDC issuer-url and client-id are required for context %q", gCtx.Name)
}

stdout := cmd.OutOrStdout()
io.Info(stdout, "Opening browser for OIDC login to %s...", gCtx.Grafana.OIDC.IssuerURL)

port := callbackPort
if port == 0 {
port = int(gCtx.Grafana.OIDC.CallbackPort)
}

token, err := auth.Login(cmd.Context(), gCtx.Grafana.OIDC, auth.LoginOptions{
CallbackPort: port,
})
if err != nil {
return fmt.Errorf("OIDC login failed: %w", err)
}

// Store tokens in the cache file, not the main config.
cache, _ := config.LoadTokenCache(cmd.Context())
cache.Set(cfg.CurrentContext, auth.NewCachedToken(token))

if err := config.WriteTokenCache(cmd.Context(), cache); err != nil {
return fmt.Errorf("saving tokens to cache: %w", err)
}

io.Success(stdout, "Successfully authenticated via OIDC")

if token.Expiry.IsZero() {
io.Info(stdout, "Token has no expiry")
} else {
io.Info(stdout, "Token expires at %s", token.Expiry.Format("2006-01-02 15:04:05"))
}

return nil
},
}

cmd.Flags().IntVar(&callbackPort, "callback-port", 0, "Fixed port for the local OIDC callback server (0 = random)")

return cmd
}

func statusCmd(configOpts *Options) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Args: cobra.NoArgs,
Short: "Show OIDC authentication status",
Example: "\n\tgrafanactl auth status",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := configOpts.loadConfigTolerant(cmd.Context())
if err != nil {
return err
}

gCtx := cfg.GetCurrentContext()
if gCtx == nil {
return fmt.Errorf("no current context set")
}

stdout := cmd.OutOrStdout()

if gCtx.Grafana == nil || gCtx.Grafana.OIDC == nil || !gCtx.Grafana.OIDC.IsConfigured() {
io.Warning(stdout, "OIDC is not configured for context %q", gCtx.Name)
return nil
}

io.Info(stdout, "OIDC provider: %s", gCtx.Grafana.OIDC.IssuerURL)
io.Info(stdout, "Client ID: %s", gCtx.Grafana.OIDC.ClientID)

cache, _ := config.LoadTokenCache(cmd.Context())
cached := cache.Get(cfg.CurrentContext)

if cached == nil || cached.AccessToken == "" {
io.Warning(stdout, "Not authenticated. Run 'grafanactl auth login' to log in.")
return nil
}

if auth.TokenNeedsRefresh(cached) {
io.Warning(stdout, "Token expired")
if cached.RefreshToken != "" {
io.Info(stdout, "Refresh token available — token will be refreshed on next command")
} else {
io.Info(stdout, "No refresh token — run 'grafanactl auth login' to re-authenticate")
}
} else {
io.Success(stdout, "Authenticated")
if cached.TokenExpiry != "" {
io.Info(stdout, "Token expires: %s", cached.TokenExpiry)
}
}

return nil
},
}

return cmd
}
28 changes: 27 additions & 1 deletion cmd/grafanactl/config/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/caarlos0/env/v11"
"github.com/grafana/grafanactl/cmd/grafanactl/fail"
"github.com/grafana/grafanactl/cmd/grafanactl/io"
"github.com/grafana/grafanactl/internal/auth"
"github.com/grafana/grafanactl/internal/config"
"github.com/grafana/grafanactl/internal/format"
"github.com/grafana/grafanactl/internal/grafana"
Expand Down Expand Up @@ -77,6 +78,7 @@ func (opts *Options) loadConfigTolerant(ctx context.Context, extraOverrides ...c
}

// LoadConfig loads the configuration file (default, or explicitly set via flags) and validates it.
// If OIDC is configured, the cached token is resolved into APIToken so downstream code is unaware of OIDC.
func (opts *Options) LoadConfig(ctx context.Context) (config.Config, error) {
validator := func(cfg *config.Config) error {
// Ensure that the current context actually exists.
Expand All @@ -87,7 +89,31 @@ func (opts *Options) LoadConfig(ctx context.Context) (config.Config, error) {
return cfg.GetCurrentContext().Validate()
}

return opts.loadConfigTolerant(ctx, validator)
cfg, err := opts.loadConfigTolerant(ctx, validator)
if err != nil {
return cfg, err
}

// If OIDC is configured, resolve the cached token into APIToken.
// This makes OIDC transparent to all downstream auth call sites.
if gCtx := cfg.GetCurrentContext(); gCtx != nil && gCtx.Grafana != nil && gCtx.Grafana.OIDC.IsConfigured() {
cache, _ := config.LoadTokenCache(ctx)
cached := cache.Get(cfg.CurrentContext)

token, refreshed, err := auth.EnsureValidToken(ctx, gCtx.Grafana.OIDC, cached)
if err != nil {
return cfg, err
}

gCtx.Grafana.APIToken = token

if refreshed {
cache.Set(cfg.CurrentContext, cached)
_ = config.WriteTokenCache(ctx, cache)
}
}

return cfg, nil
}

// LoadRESTConfig loads the configuration file and constructs a REST config from it.
Expand Down
1 change: 1 addition & 0 deletions cmd/grafanactl/root/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func Command(version string) *cobra.Command {
rootCmd.SetErr(os.Stderr)
rootCmd.SetIn(os.Stdin)

rootCmd.AddCommand(config.AuthCommand())
rootCmd.AddCommand(config.Command())
rootCmd.AddCommand(resources.Command())

Expand Down
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ services:
networks:
- grafana

dex:
image: dexidp/dex:v2.41.1
container_name: grafanactl-dex
restart: unless-stopped
ports:
- "5556:5556"
volumes:
- ./testdata/dex/config.yaml:/etc/dex/config.yaml:ro
command: ["dex", "serve", "/etc/dex/config.yaml"]
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5556/dex/.well-known/openid-configuration"]
interval: 5s
timeout: 3s
retries: 10
networks:
- grafana

grafana:
image: grafana/grafana:12.3@sha256:ba93c9d192e58b23e064c7f501d453426ccf4a85065bf25b705ab1e98602bfb1
container_name: grafanactl-grafana
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.19.0
k8s.io/apimachinery v0.35.1
k8s.io/cli-runtime v0.35.1
Expand Down Expand Up @@ -85,7 +86,6 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
Expand Down
Loading