How to add a new provider to gcx — from interface to registry registration.
Providers are compile-time extension points that contribute Cobra commands and typed configuration to gcx. A provider encapsulates everything needed to manage a specific Grafana product (e.g., SLO, OnCall, Synthetic Monitoring): its CLI commands, its config schema, and its validation logic.
When to create a provider:
- You want to add top-level commands for a Grafana Cloud product
- The product requires product-specific authentication or configuration keys
- You want those config keys to integrate with
gcx config setandGRAFANA_PROVIDER_*environment variables automatically
Architecture reference: patterns.md – Provider Plugin System (Pattern 11), config-system.md (Provider config section).
Create a new package under internal/providers/ for your provider, or add it
to an existing package. The interface is defined in internal/providers/provider.go:
type Provider interface {
Name() string
ShortDesc() string
Commands() []*cobra.Command
Validate(cfg map[string]string) error
ConfigKeys() []ConfigKey
}A minimal skeleton:
package slo
import (
"github.com/spf13/cobra"
"github.com/grafana/gcx/internal/providers"
)
// SLOProvider manages Grafana SLO resources.
type SLOProvider struct{}
var _ providers.Provider = &SLOProvider{}
func (p *SLOProvider) Name() string { return "slo" }
func (p *SLOProvider) ShortDesc() string { return "Manage Grafana SLO resources." }Naming rules:
Name()is the map key used in config and env vars — use lowercase, no spacesName()must be unique across all registered providersShortDesc()should be one sentence ending with a period
ConfigKeys() tells gcx which config keys your provider uses and which
are secrets. This drives the secure-by-default redaction in gcx config view.
func (p *SLOProvider) ConfigKeys() []providers.ConfigKey {
return []providers.ConfigKey{
{Name: "token", Secret: true}, // redacted in config view
{Name: "url", Secret: false}, // shown in plain text
{Name: "org-id", Secret: false},
}
}Redaction model (secure by default):
| Situation | Behaviour |
|---|---|
Known provider, Secret: true key |
Redacted |
Known provider, Secret: false key |
Shown as-is |
| Known provider, undeclared key | Redacted |
| Unknown provider (not in registry) | All values redacted |
| Empty value | Never redacted |
Declare every key your provider reads, otherwise it will be silently redacted
when users run gcx config view.
Validate receives the full provider config as a map[string]string and
returns an error if required keys are missing or malformed. It is called by
your commands before making API calls.
import "fmt"
func (p *SLOProvider) Validate(cfg map[string]string) error {
if cfg["token"] == "" {
return fmt.Errorf("slo provider: token is required; "+
"set it with: gcx config set contexts.<ctx>.providers.slo.token <value>")
}
return nil
}Guidelines:
- Return actionable error messages that tell the user what to do
- Only validate what is strictly required — optional keys should not fail here
- Do not perform network calls inside
Validate
Commands() returns the Cobra commands to add under the gcx root. Each
command receives provider config by reading the active context at call time.
Follow the Options pattern used by all other commands — accept *cmdconfig.Options
as a constructor argument and call configOpts.LoadConfig(cmd.Context()) inside RunE:
import cmdconfig "github.com/grafana/gcx/cmd/gcx/config"
// Commands returns a "slo" command group with subcommands underneath it.
// Config flags are bound once on the parent's PersistentFlags so every
// subcommand inherits them automatically.
func (p *SLOProvider) Commands() []*cobra.Command {
configOpts := &cmdconfig.Options{}
sloCmd := &cobra.Command{
Use: "slo",
Short: p.ShortDesc(),
}
// Bind once on the parent — all subcommands inherit these flags.
configOpts.BindFlags(sloCmd.PersistentFlags())
sloCmd.AddCommand(newListCommand(configOpts))
// sloCmd.AddCommand(newGetCommand(configOpts)) // add more subcommands here
return []*cobra.Command{sloCmd}
}
func newListCommand(configOpts *cmdconfig.Options) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List SLO definitions.",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := configOpts.LoadConfig(cmd.Context())
if err != nil {
return err
}
curCtx := cfg.GetCurrentContext()
providerCfg := curCtx.Providers["slo"] // map[string]string
// Validate before use
p := &SLOProvider{}
if err := p.Validate(providerCfg); err != nil {
return err
}
token := providerCfg["token"]
url := providerCfg["url"]
// ... make API calls ...
_ = token
_ = url
return nil
},
}
}Wiring note: The root command automatically adds every provider's commands
via p.Commands()... — you do not need to touch cmd/gcx/root/command.go.
If your provider queries Prometheus datasources, use github.com/grafana/promql-builder/go/promql
to construct PromQL expressions instead of fmt.Sprintf. This eliminates string
injection risks and makes complex queries composable and readable.
import "github.com/grafana/promql-builder/go/promql"
func buildMetricQuery(metricName, uuidRegex string) (string, error) {
expr, err := promql.Vector(metricName).
LabelMatchRegexp("grafana_slo_uuid", uuidRegex).
Build()
if err != nil {
return "", err
}
return expr.String(), nil
}
// Output: grafana_slo_sli_window{grafana_slo_uuid=~"uuid1|uuid2"}func buildBurnRateQuery(uuidRegex string) (string, error) {
label := "grafana_slo_uuid"
successRate := promql.Sum(
promql.AvgOverTime(
promql.Vector("grafana_slo_success_rate_5m").
LabelMatchRegexp(label, uuidRegex).Range("1h"),
),
).By([]string{label})
totalRate := promql.Sum(
promql.AvgOverTime(
promql.Vector("grafana_slo_total_rate_5m").
LabelMatchRegexp(label, uuidRegex).Range("1h"),
),
).By([]string{label})
// burn_rate = (1 - clamp_max(success/total, 1)) / (1 - objective)
errorRate := promql.Sub(promql.N(1),
promql.ClampMax(promql.Div(successRate, totalRate), 1))
allowedError := promql.Sub(promql.N(1),
promql.Vector("grafana_slo_objective").
LabelMatchRegexp(label, uuidRegex))
burnRate := promql.Div(errorRate, allowedError).On([]string{label})
expr, err := burnRate.Build()
if err != nil {
return "", err
}
return expr.String(), nil
}Join multiple resource UUIDs with | and pass as a regex matcher via
.LabelMatchRegexp(). Group results back to individual resources using
sum by (uuid_label)(...). This minimizes the number of Prometheus queries
while returning per-resource values.
uuids := []string{"abc123", "def456", "ghi789"}
uuidRegex := strings.Join(uuids, "|")
query, _ := buildMetricQuery("grafana_slo_sli_window", uuidRegex)
// Result: grafana_slo_sli_window{grafana_slo_uuid=~"abc123|def456|ghi789"}Always fetch all available metrics regardless of the --output format. The
output format controls display, not data acquisition. Table codecs
choose which columns to show; JSON/YAML codecs serialize the full struct. See
Pattern 13 in patterns.md.
Reference: internal/providers/slo/definitions/status.go, internal/query/prometheus/client.go
Providers self-register using the Register() function in your provider's init() function.
Add this to your provider package (typically in internal/providers/{provider}/provider.go):
func init() {
providers.Register(&SLOProvider{})
}The Register() function appends your provider to the global registry automatically. Once registered via init():
- Its commands appear under
gcx - Its name and description appear in
gcx providers - Its secrets are correctly redacted by
gcx config view - Its config is loaded from YAML and env vars automatically
This self-registration pattern (via init()) is handled by Go's import system — just ensure your provider package is imported somewhere in the application startup (e.g., in cmd/gcx/root/command.go). Reference: internal/providers/slo/provider.go for the full implementation.
Provider config lives in the providers map within a context:
# ~/.config/gcx/config.yaml
current-context: prod
contexts:
prod:
grafana:
server: https://grafana.example.com
token: gf_...
providers:
slo:
token: glsa_...
url: https://slo.example.com
oncall:
token: glsa_...Set individual keys with the config command (dotted-path notation):
gcx config set contexts.prod.providers.slo.token glsa_abc123
gcx config set contexts.prod.providers.slo.url https://slo.example.comAny config key can be set via environment variable using the pattern:
GRAFANA_PROVIDER_{PROVIDER_NAME}_{CONFIG_KEY}=value
Provider names and keys are lowercased automatically, and underscores in the
config key portion are converted to dashes (matching the kebab-case YAML
convention). The suffix after GRAFANA_PROVIDER_ is split on the first
underscore only — everything before it becomes the provider name, everything
after becomes the config key (with _ → - normalization):
# GRAFANA_PROVIDER_SLO_TOKEN → provider=slo, key=token
# GRAFANA_PROVIDER_SLO_ORG_ID → provider=slo, key=org-id
export GRAFANA_PROVIDER_SLO_TOKEN=glsa_abc123
export GRAFANA_PROVIDER_SLO_ORG_ID=42Env vars take precedence over YAML config values.
Use the mockProvider helper pattern from internal/providers/provider_test.go
when writing tests that need a fake provider:
type mockProvider struct {
name string
shortDesc string
commands []*cobra.Command
validateFn func(cfg map[string]string) error
configKeys []providers.ConfigKey
}
var _ providers.Provider = &mockProvider{}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) ShortDesc() string { return m.shortDesc }
func (m *mockProvider) Commands() []*cobra.Command { return m.commands }
func (m *mockProvider) Validate(cfg map[string]string) error { return m.validateFn(cfg) }
func (m *mockProvider) ConfigKeys() []providers.ConfigKey { return m.configKeys }Test the interface contract directly:
func TestSLOProvider(t *testing.T) {
p := &SLOProvider{}
t.Run("name is stable", func(t *testing.T) {
assert.Equal(t, "slo", p.Name())
})
t.Run("token is required", func(t *testing.T) {
err := p.Validate(map[string]string{})
assert.ErrorContains(t, err, "token is required")
})
t.Run("valid config passes", func(t *testing.T) {
err := p.Validate(map[string]string{"token": "glsa_xxx"})
assert.NoError(t, err)
})
t.Run("token declared as secret", func(t *testing.T) {
keys := p.ConfigKeys()
for _, k := range keys {
if k.Name == "token" {
assert.True(t, k.Secret, "token must be declared as secret")
return
}
}
t.Fatal("token key not declared in ConfigKeys")
})
}Test redaction behaviour separately using providers.RedactSecrets directly —
see internal/providers/redact_test.go for table-driven examples.
When implementing a new provider (see also provider-checklist.md for UX compliance requirements):
- Struct implements all five
Providerinterface methods -
Name()is lowercase, unique, and stable (it is the map key in config files) - All config keys read by commands are declared in
ConfigKeys() - Secret keys (
passwords,tokens,api_keys) haveSecret: true -
Validatereturns a helpful error message pointing to theconfig setcommand - Provider is added to
internal/providers/registry.go:All() -
make buildsucceeds -
make testspasses -
gcx providerslists the new provider -
gcx config viewredacts secrets correctly