gcx's resource model is built on a Kubernetes-style representation borrowed directly from k8s.io/apimachinery. Every Grafana resource — dashboard, folder, alert rule — is represented as an unstructured.Unstructured object carrying apiVersion, kind, metadata, and spec fields. This design choice unlocks use of the full Kubernetes client-go ecosystem, including dynamic clients, paginators, and server-side apply semantics.
The central pipeline that enables user-facing commands like gcx resources get dashboards/my-dash is:
User input string
|
v
[Selector] -- partial spec from user input
(selector.go)
|
v (via discovery.Registry.MakeFilters)
[Filter] -- fully-resolved spec with complete GVK
(filter.go)
|
v (via dynamic client)
[Resource] -- concrete fetched/read object
(resources.go)
|
v (via Processors)
[Transformed Resource] -- ready for write/push
(process/)
File: internal/resources/resources.go
┌──────────────────────────────────────────────────────────┐
│ Resource │
│ │
│ Raw utils.GrafanaMetaAccessor ←─ typed Grafana API │
│ Object unstructured.Unstructured ←─ raw K8s object │
│ Source SourceInfo ←─ origin tracking │
└──────────────────────────────────────────────────────────┘
Resource (line 28) wraps two complementary representations:
Object: the rawunstructured.Unstructuredmap (map[string]any) fromk8s.io/apimachinery. This is what gets serialized to JSON/YAML and sent to the API.Raw: aGrafanaMetaAccessor— Grafana's typed accessor layer over the unstructured object. It provides methods likeGetManagerProperties(),SetManagerProperties(),GetSourceProperties(),GetFolder()that would otherwise require manual map traversal.
type SourceInfo struct {
Path string // absolute file path on disk
Format format.Format // JSON or YAML
}Every Resource carries a SourceInfo (line 374) recording where it came from. This enables:
- Round-trip fidelity: pulled YAML stays YAML on push
- Error messages: "error in file://./resources/dashboards.yaml"
- The
ServerFieldsStripperprocessor to preserve the path annotation
Resources carry manager metadata in annotations (via GrafanaMetaAccessor):
grafana.app/manager-kind— which tool manages the resource (gcx usesutils.ManagerKindKubectlas placeholder, line 19)grafana.app/manager-identity— identity string ("gcx")grafana.app/source-path— original file path
IsManaged() (line 161) returns true when the manager kind matches ResourceManagerKind. Resources managed by the UI (with grafana.app/saved-from-ui annotation) or other tools are protected from accidental overwrites unless --include-managed is passed.
type ResourceRef string
// Format: "group/version/kind/namespace-name"Ref() (line 89) generates a unique stable key used as the map key in Resources.
type Resources struct {
collection map[ResourceRef]*Resource // deduplicates by ref
onChangeFuncs []func(resource *Resource)
}Key operations:
Add()— deduplicates: adding the same resource twice overwrites (line 235)ForEach()— sequential iteration with error propagationForEachConcurrently(ctx, maxInflight, fn)— bounded-concurrency iteration viaerrgroup.SetLimit(line 283)GroupByKind()— returnsmap[string]*Resourcesfor writer groupingMerge()— merge two collections (used by serve command for live reload)OnChange(cb)— event hook called on everyAdd()(used by serve for live updates)
File: internal/resources/descriptor.go
A Descriptor is the complete, unambiguous identity of a resource type:
type Descriptor struct {
GroupVersion schema.GroupVersion // e.g. {Group: "dashboard.grafana.app", Version: "v1alpha1"}
Kind string // e.g. "Dashboard"
Singular string // e.g. "dashboard"
Plural string // e.g. "dashboards"
}It provides both GroupVersionKind() (for API calls) and GroupVersionResource() (for k8s client routing, which uses the plural form). The Matches(gvk) method (line 64) is used by Filter.Matches() to check if a resource belongs to a filter.
String representation: dashboards.v1alpha1.dashboard.grafana.app
File: internal/resources/selector.go
A Selector is an unvalidated user specification parsed from CLI arguments:
type Selector struct {
Type FilterType // All | Single | Multiple
GroupVersionKind PartialGVK // partial — may lack group/version
ResourceUIDs []string // resource names, if specified
}PartialGVK (line 140) accepts any level of specificity:
Input string format: <resource>[.<version>.<group>][/<uid1>[,<uid2>...]]
Parsing rules (SplitN on "."):
1 part: "dashboards" → Resource="dashboards"
2 parts: "dashboards.folder" → Resource="dashboards", Group="folder"
3 parts: "dashboards.v1alpha1.dashboard.grafana.app"
→ Resource="dashboards", Version="v1alpha1",
Group="dashboard.grafana.app"
FilterType is assigned during parsing (line 102-125):
- No UID →
FilterTypeAll - One UID →
FilterTypeSingle - Multiple UIDs (comma-separated) →
FilterTypeMultiple
"dashboards" → FilterTypeAll, Resource="dashboards"
"dashboards/foo" → FilterTypeSingle, Resource="dashboards", UIDs=["foo"]
"dashboards/foo,bar" → FilterTypeMultiple, Resource="dashboards", UIDs=["foo","bar"]
"dashboards.v1alpha1.dashboard.grafana.app/foo,bar"
→ FilterTypeMultiple, Version="v1alpha1",
Group="dashboard.grafana.app", UIDs=["foo","bar"]
File: internal/resources/filter.go
A Filter is a Selector that has been resolved against the discovery registry. It replaces PartialGVK with a concrete Descriptor:
type Filter struct {
Type FilterType
Descriptor Descriptor // complete GVK + plural/singular — fully resolved
ResourceUIDs []string
}Filter.Matches(res Resource) (line 65) checks both the descriptor (GVK equality) and the UIDs list. Filters.Matches(res) (line 89) returns true if any filter in the list matches — empty filters match all resources.
File: internal/resources/discovery/registry.go, line 80
Selector (PartialGVK)
|
v registry.MakeFilters(opts)
|
├── version specified? ──── LookupPartialGVK ─────────→ single Descriptor → Filter
|
├── preferredVersionOnly? ─ LookupPartialGVK ─────────→ single Descriptor → Filter
|
└── all versions? ───────── LookupAllVersionsForPartialGVK → []Descriptor → []Filters
MakeFiltersOptions.PreferredVersionOnly controls whether to resolve to one filter per type (pull uses all versions; push uses preferred).
Files: internal/resources/discovery/registry.go, registry_index.go
Grafana API (/apis endpoint)
|
v k8s discovery.Client.ServerGroupsAndResources()
|
[APIGroup list] [APIResourceList list]
|
v FilterDiscoveryResults() ← strips ignoredResourceGroups + non-namespaced + subresources
|
v RegistryIndex.Update()
|
├── shortGroups: {"dashboard": "dashboard.grafana.app", ...}
├── longGroups: {"dashboard.grafana.app": {}, ...}
├── preferredVersions: {"dashboard.grafana.app": {Group:..., Version:"v1"}, ...}
├── descriptors: {GroupVersion → []Descriptor}
├── kindNames: {"Dashboard": [{Group:"dashboard.grafana.app", Kind:"Dashboard"}]}
├── singularNames: {"dashboard": [...]}
└── pluralNames: {"dashboards": [...]}
File: internal/resources/discovery/registry_index.go
The index resolves a partial name string to candidates via getKindCandidates() (line 258), which checks three maps in order: kindNames → singularNames → pluralNames. This means "Dashboard", "dashboard", and "dashboards" all resolve to the same candidates.
filterCandidates() (line 271) then narrows by group and version, falling back to preferredVersions when version is omitted.
Short group names work: "folders.folder" resolves "folder" via shortGroups to "folder.grafana.app" (line 280-283). The short name is the first DNS label: makeShortName("folder.grafana.app") → "folder".
The ignoredResourceGroups global (line 19) excludes these groups from discovery:
apiregistration.k8s.io — internal K8s
featuretoggle.grafana.app — read-only feature flags
service.grafana.app — internal service registry
userstorage.grafana.app — internal user storage
notifications.alerting.grafana.app — pending decision
iam.grafana.app — identity/access management
Additionally, FilterDiscoveryResults() (line 181) excludes:
- Non-namespaced resources (line 207) — all Grafana resources are namespaced
- Subresources (containing
/in name, line 212) — e.g.dashboards/status
Grafana follows standard Kubernetes API versioning: each group advertises a preferredVersion that clients should use by default. The registry tracks this in preferredVersions map. When a user specifies "dashboards" without a version, the preferred version (e.g. v1) is selected automatically.
File: internal/resources/remote/remote.go (interface), internal/resources/process/ (implementations)
// Defined in remote/remote.go
type Processor interface {
Process(res *resources.Resource) error
}Processors transform resources in-place before push or after pull. They are passed as []Processor in PushRequest and PullRequest and applied sequentially per resource.
File: internal/resources/process/managerfields.go
Applied during push (wired in cmd/gcx/resources/push.go line 148). Writes manager metadata into annotations on resources that are managed by gcx:
r.Raw.SetManagerProperties({Kind: ResourceManagerKind, Identity: "gcx"})
r.Raw.SetSourceProperties({Path: "file:///path/to/resource.yaml"})
Skipped if r.IsManaged() returns false — protects externally-managed resources.
Skipped entirely when --omit-manager-fields CLI flag is set.
File: internal/resources/process/serverfields.go
Applied during pull (wired in cmd/gcx/resources/pull.go line 121). Removes server-generated ephemeral fields to produce clean, round-trippable files:
Annotations removed:
grafana.app/createdBy,grafana.app/updatedBy,grafana.app/updatedTimestamp— alwaysgrafana.app/manager-*,grafana.app/source-*— only for gcx-managed resources (re-added on push)
Labels removed:
grafana.app/deprecatedInternalID
Also reconstructs the object as a clean minimal structure (apiVersion, kind, metadata, spec) stripping any other server-injected top-level fields.
File: internal/resources/process/namespace.go
Always applied first in the push pipeline (line 145 in push.go). Overwrites the metadata.namespace of every resource with the target context's namespace. This enables pulling from one org/stack and pushing to another without manually editing files.
Files written by the pull pipeline use a versioned path that includes Kind, API version, and group:
{OutputDir}/{Kind}.{Version}.{Group}/{Name}.{ext}
Example: ./resources/Dashboard.v1alpha1.dashboard.grafana.app/my-dash.yaml
This replaces the old format ({Kind}/{Name}.{ext}) to make the API version
and group unambiguous, which is important when multiple versions of the same
resource type are pulled simultaneously.
PUSH pipeline (cmd/gcx/resources/push.go):
procs = [NamespaceOverrider(cfg.Namespace), ManagerFieldsAppender{}]
PushRequest{Resources, Processors: procs, ...}
→ pusher.Push() calls Process() on each resource before Create/Update
PULL pipeline (cmd/gcx/resources/pull.go):
PullRequest{Processors: [ServerFieldsStripper{}], ...}
→ puller.Pull() calls Process() on each resource after fetching from API
Files: internal/resources/adapter/adapter.go, internal/resources/adapter/typed.go, internal/resources/adapter/identity.go, internal/resources/adapter/router.go
Some resource types are backed by provider REST APIs (SLO, Synthetic Monitoring, Alert)
rather than by the Grafana k8s-compatible /apis endpoint. These types plug into the
unified resources pipeline via the ResourceAdapter interface:
ResourceAdapter interface
+-- List(ctx, ListOptions) → (*UnstructuredList, error)
+-- Get(ctx, name, GetOptions) → (*Unstructured, error)
+-- Create(ctx, obj, CreateOptions) → (*Unstructured, error)
+-- Update(ctx, obj, UpdateOptions) → (*Unstructured, error)
+-- Delete(ctx, name, DeleteOptions) → error
+-- Descriptor() Descriptor
+-- Aliases() []string
+-- Schema() json.RawMessage
+-- Example() json.RawMessage
adapter.Factory is func(ctx context.Context) (ResourceAdapter, error) — a lazy
constructor that is only called on first use and its result cached for the router's
lifetime.
Most providers use TypedCRUD[T] to implement ResourceAdapter without hand-writing
the marshal/unmarshal boilerplate. TypedCRUD wraps typed Go functions (ListFn,
GetFn, CreateFn, UpdateFn, DeleteFn) and handles:
- Wrapping domain objects in
TypedObject[T]— a generic K8s-style envelope (TypeMeta+ObjectMeta+Spec T) - Converting between typed domain objects and
unstructured.Unstructured - Stripping server-managed fields (
StripFields) - Client-side get-by-name fallback when
GetFnis nil (lists + filters)
The type constraint ResourceNamer (value-type subset of ResourceIdentity)
requires domain types to implement GetResourceName() string. The full
ResourceIdentity interface adds SetResourceName(string) for round-trip
support (pointer receiver).
SetResourceName contract: SetResourceName must restore enough internal
state for CRUD operations to work — not just set the name string. For types
with numeric API IDs encoded in a slug-id composite name (e.g.
grafana-instance-health-5594), SetResourceName must parse and restore the
numeric ID. Failing to do so causes UpdateFn/DeleteFn to receive a zero ID
after a K8s round-trip through the resources push/pull pipeline. Shared
helpers in adapter/slug.go (ExtractIDFromSlug, ExtractInt64IDFromSlug)
support this pattern.
ResourceIdentity interface (pointer types)
+-- GetResourceName() string -- extract identity for metadata.name
+-- SetResourceName(name string) -- restore identity after K8s round-trip
ResourceNamer interface (value types — TypedCRUD constraint)
+-- GetResourceName() string
TypedObject[T ResourceNamer]
+-- TypeMeta (apiVersion, kind)
+-- ObjectMeta (name, namespace)
+-- Spec T (domain object)
TypedCRUD[T ResourceNamer]
+-- ListFn, GetFn, CreateFn, UpdateFn, DeleteFn -- typed function pointers
+-- List(ctx) → []TypedObject[T] -- typed public API
+-- AsAdapter() → ResourceAdapter -- bridge to unstructured pipeline
Providers implement TypedRegistrations() []adapter.Registration on the Provider
interface. providers.Register(p) auto-registers both the provider and its adapter
registrations atomically — a single call in init() populates both registries:
// In providers/registry.go
func Register(p Provider) {
registry = append(registry, p)
for _, reg := range p.TypedRegistrations() {
adapter.Register(reg) // auto-register adapters
}
}TypedRegistration[T] bridges TypedCRUD to the Registration system:
TypedRegistration[T ResourceNamer]
+-- Descriptor, Aliases, GVK, Schema, Example
+-- Factory func(ctx) (*TypedCRUD[T], error)
+-- ToRegistration() → Registration // wraps Factory to return ResourceAdapterThis replaces the old pattern where providers called adapter.Register() directly
in their init() functions alongside providers.Register().
ResourceClientRouter wraps both a DynamicClient (k8s path) and a
map[GVK]Factory (adapter path). For every CRUD call:
- Look up GVK in the factory map
- If found: lazily initialize the adapter (first call only), then delegate
- If not found: fall through to the k8s dynamic client
This makes provider-backed types transparent to the rest of the pipeline — the Pusher, Puller, and Deleter call the router without knowing whether the underlying client is a REST adapter or the k8s dynamic client.
Provider descriptors are injected into the RegistryIndex via RegisterStatic(desc, aliases)
so that provider types appear in resources schemas output and resolve correctly from
selector strings like "slos" or "rules".
Grafana 12+ exposes its API as a Kubernetes-style API server (using grafana/grafana/pkg/apimachinery). The same apiVersion/kind/metadata/spec structure used by Kubernetes is used by Grafana's API. This was not a gcx design choice — it is a direct consequence of Grafana's server architecture.
Given that reality, using k8s.io/client-go and k8s.io/apimachinery directly provides:
- Dynamic discovery —
ServerGroupsAndResources()returns all supported types without needing hardcoded lists; new resource types in Grafana are automatically available - Pagination —
k8s.io/client-go's pager handles continuation tokens transparently - Dry-run semantics — the K8s
dryRun: Alloption maps directly to Grafana's API - Unstructured representation —
map[string]anyaccommodates any resource shape without pre-generated Go types for each Grafana resource kind - Familiar UX — the kubectl-style CLI patterns (
resources get dashboards/foo, context switching) are immediately recognizable to Grafana users who work with Kubernetes
PartialGVK Descriptor
┌─────────────────────┐ ┌──────────────────────────────┐
│ Group string │ ──via──→ │ GroupVersion schema.GV │
│ Version string │ registry │ Kind string │
│ Resource string │ │ Singular string │
└─────────────────────┘ │ Plural string │
└──────────────────────────────┘
│ │
│ │
v v
Selector Filter
┌─────────────────────┐ ┌──────────────────────────────┐
│ Type FilterType │ ──via──→ │ Type FilterType │
│ GVK PartialGVK │ registry │ Descriptor Descriptor │
│ UIDs []string │ │ ResourceUIDs []string │
└─────────────────────┘ └──────────────────────────────┘
│
│ used by
v
Resource
┌──────────────────────────────┐
│ Raw GrafanaMetaAccessor │
│ Object unstructured.Unstruct │
│ Source SourceInfo │
└──────────────────────────────┘
│
│ collected into
v
Resources
┌──────────────────────────────┐
│ collection map[ResourceRef] │
│ ForEachConcurrently(...) │
│ GroupByKind() → map[string] │
└──────────────────────────────┘
| File | Purpose |
|---|---|
internal/resources/resources.go |
Resource, Resources, SourceInfo, ResourceRef types |
internal/resources/descriptor.go |
Descriptor, Descriptors types |
internal/resources/selector.go |
Selector, PartialGVK, ParseSelectors() |
internal/resources/filter.go |
Filter, Filters, FilterType |
internal/resources/discovery/registry.go |
Registry, MakeFilters(), FilterDiscoveryResults() |
internal/resources/discovery/registry_index.go |
RegistryIndex, lookup/resolution logic |
internal/resources/remote/remote.go |
Processor interface |
internal/resources/process/managerfields.go |
ManagerFieldsAppender |
internal/resources/process/serverfields.go |
ServerFieldsStripper |
internal/resources/process/namespace.go |
NamespaceOverrider |
cmd/gcx/resources/push.go |
Push pipeline wiring (processors, registry, filters) |
cmd/gcx/resources/pull.go |
Pull pipeline wiring (processors, registry, filters) |
internal/resources/adapter/adapter.go |
ResourceAdapter interface and Factory type |
internal/resources/adapter/identity.go |
ResourceIdentity and ResourceNamer interfaces |
internal/resources/adapter/typed.go |
TypedCRUD[T], TypedObject[T], TypedRegistration[T] — generic adapter framework |
internal/resources/adapter/register.go |
Global Register(), AllRegistrations() for self-registration |
internal/resources/adapter/router.go |
ResourceClientRouter — routes CRUD to adapter or dynamic client |
internal/resources/discovery/openapi.go |
SchemaFetcher — fetches OpenAPI v3 schemas with disk caching; used by resources schemas |
internal/config/context.go |
ContextWithName / ContextNameFromCtx — threads config context name through context.Context to adapter factories |