Manage Grafana instance resources declaratively - dashboards, data sources, folders, alerting, teams, and service accounts. Works with both self-hosted Grafana and Grafana Cloud instances.
| Resource Type | Description | Native ID |
|---|---|---|
GRAFANA::Core::Folder |
Dashboard folders with nested hierarchy support | uid |
GRAFANA::Core::Dashboard |
Dashboard definitions (JSON model) | uid |
GRAFANA::Core::DataSource |
Data source connections (Prometheus, Loki, etc.) | uid |
GRAFANA::Core::Team |
Teams for organizing users and permissions | id |
GRAFANA::Core::ServiceAccount |
Service accounts for programmatic API access | id |
| Resource Type | Description | Native ID |
|---|---|---|
GRAFANA::Alerting::AlertRule |
Individual alert rules with query conditions | uid |
GRAFANA::Alerting::ContactPoint |
Notification channels (Slack, email, PagerDuty, etc.) | uid |
GRAFANA::Alerting::NotificationPolicy |
Alert routing tree (singleton per org) | receiver |
GRAFANA::Alerting::MuteTiming |
Time windows for suppressing notifications | name |
GRAFANA::Alerting::MessageTemplate |
Go templates for notification formatting | name |
A notification policy names the contact point it routes to. Point receiver at
the contact point's name resolvable rather than repeating the name as a
string:
local onCall = new contact_point.ContactPoint {
label = "on-call"
name = "on-call"
contactPointType = "slack"
settings = new {
url = "https://hooks.slack.com/services/…"
recipient = "#alerts"
}
}
onCall // a `local` binding is not emitted on its own; name it to add it to the forma
new notification_policy.NotificationPolicy {
label = "default-routing"
receiver = onCall.res.name
}Grafana rejects a policy tree that names a receiver which does not exist, so the
ordering matters. The resolvable is what tells formae to create the contact
point before the policy, and to reset the policy before deleting the contact
point on teardown. Receivers named inside routes are part of an opaque JSON
string and carry no such edge.
A contact point's settings are a single typed settings object whose keys are
the options the declared contactPointType accepts:
new contact_point.ContactPoint {
label = "slack-alerts"
name = "Slack Alerts"
contactPointType = "slack"
settings = new {
url = "https://hooks.slack.com/services/xxx/yyy/zzz"
recipient = "#alerts"
}
}The vocabulary comes from Grafana's own notifier metadata, so every option any
notifier type declares is available as a property. Nested blocks (a webhook's
http_config.oauth2.tls_config, an OpsGenie responders entry) are typed
objects of their own, and every free-text option accepts a formae.Resolvable,
so a setting can flow in from another resource - even one managed by a
different plugin - in a single apply:
settings = new {
integrationKey = pdIntegration.res.integrationKey
severity = "critical"
}A key no notifier declares - a misspelled one - is a Pkl type error, caught before the forma reaches the agent. A key that belongs to a different notifier type than the one declared still evaluates, and the plugin rejects it when the contact point is submitted, naming the key and the notifier type that does not accept it. Nothing is written in that case, which matters because Grafana stores and echoes back any key it is given: an unrejected wrong-for-type key would sit in the contact point doing nothing, and never be reported as drift.
Options Grafana classifies as secret - a Slack url or token, a PagerDuty
integrationKey, a webhook password - additionally accept a
formae.SecretValue and are hashed at rest. The classification is Grafana's
own, taken from its notifier metadata rather than from a list this plugin
keeps. Such an option can be given at either of two grades:
- A reference (
password = mySecret.res.secretValue) keeps the plaintext out of formae's state entirely: state holds the reference, the value is resolved per apply, andformae extractreturns the reference rather than a value. - A literal (
password = "hunter2") persists as a digest. The plaintext still sits in your forma, so prefer a reference anywhere but a throwaway local instance.
Grafana stores a secret-classified option encrypted and returns the literal
[REDACTED] in its place on every read path. The plugin declines to report a
value it cannot observe, so formae keeps the value you declared rather than
recording the sentinel, and the contact point does not drift on every sync.
That is a deliberate loss of observability, not a guarantee that such a secret never drifts. Because nothing is ever read back for the option, formae cannot tell whether the secret in Grafana was rotated, replaced or removed out of band: the divergence is invisible, not absent. Reconciling a suspected divergence therefore means re-applying the declared value, not syncing Grafana's. Every non-secret option is reported as Grafana holds it and drift-checked individually, so an out-of-band edit to one of those is still reported as a change to that key alone.
The classification is per option name rather than per notifier type, because
that is how Grafana's metadata reads: url is secret-classified everywhere
since some types mark it secure. Grafana redacts it only for those types, so a
webhook's url comes back in full and stays drift-checked normally.
The settings type is generated from the notifier metadata of the Grafana the
plugin was built against, so the set of authorable options is closed. An option
added by a newer Grafana cannot be authored until the schema is regenerated and
the plugin republished: the forma fails to evaluate rather than reaching a
Grafana that would have accepted the option. An escape hatch for options newer
than the schema was considered and deliberately deferred - it would reopen the
type, and with it the misspelling check that closing it buys.
Configure a Grafana target in your forma file:
import "@grafana/grafana.pkl"
new formae.Target {
label = "my-grafana"
namespace = "GRAFANA"
config = new grafana.Config {
url = "https://grafana.example.com"
// orgId = 1 // optional, defaults to token's org
// proxyUrl = "socks5://localhost:1080" // optional, route this target through a proxy
}
}Set proxyUrl to route this target's HTTP traffic through a SOCKS5 or HTTP
proxy - for example, a Grafana reachable only through a corporate proxy or a
jump host. It accepts socks5://, socks5h:// or http:// (socks5 and
socks5h are aliases in Go and behave identically); there is no https://
proxy support and no support for proxy credentials yet, and a malformed or
unsupported proxyUrl is rejected when the client is built. Setting it makes
this target ignore the ambient HTTP_PROXY, HTTPS_PROXY and NO_PROXY
environment variables entirely; leaving it unset changes nothing, and the
target keeps honouring those variables exactly as before.
The Grafana target authenticates with one of two methods:
auth takes one of two strategies, TokenAuth or BasicAuth. Every credential
field in them accepts either a literal string or a formae resolvable, so the
credential can be sourced from a secret and resolved live at apply time. There is
no credential in the agent environment, and no agent restart when the credential
rotates.
TokenAuth carries a Grafana service account token (or a legacy API key), sent
as a bearer token. Prefer it: a service account token has a scoped role and can
be revoked on its own, where the admin password is an instance superuser
credential shared with the interactive login.
import "@aws/secretsmanager/secret.pkl" as secretmod
// A secret holding the service account token.
local grafanaToken = new secretmod.Secret {
label = "grafana-token"
name = "grafana-agent-token"
}
new formae.Target {
label = "my-grafana"
namespace = "GRAFANA"
config = new grafana.Config {
url = "https://grafana.example.com"
auth = new grafana.TokenAuth {
token = grafanaToken.res.secretValue
}
}
}BasicAuth carries a username and password:
import "@aws/secretsmanager/secret.pkl" as secretmod
// A secret holding {"username": "...", "password": "..."} as JSON.
local grafanaCreds = new secretmod.Secret {
label = "grafana-admin"
name = "grafana-admin-creds"
}
new formae.Target {
label = "my-grafana"
namespace = "GRAFANA"
config = new grafana.Config {
url = "https://grafana.example.com"
auth = new grafana.BasicAuth {
username = grafanaCreds.res.secretValue.json("username")
password = grafanaCreds.res.secretValue.json("password")
}
}
}When a credential is a resolvable, the target config persists only the reference to the secret; the plaintext value is resolved per plugin call and never lands in the datastore. Any formae secret works here (for example an AWS Secrets Manager secret, an Azure Key Vault secret, or a Kubernetes Secret), so the credential can live alongside the infrastructure it protects.
A credential written as a literal string gets no such protection: it is ordinary target configuration and is stored as given. Use a literal only for a throwaway local instance, and a resolvable everywhere else.
When auth is set it is used as given: GRAFANA_AUTH is not consulted, and an
incomplete block (an empty token, a username without a password) is an error
rather than a silent fallback, so a broken secret reference cannot quietly
downgrade the target to whatever credential the agent happens to carry.
Used when auth is not set. Supported formats:
| Format | Example |
|---|---|
| Service account token | glsa_xxxxxxxxxxxx |
| API key (legacy) | eyJrIjoi... |
| Basic auth | admin:password |
export GRAFANA_AUTH="glsa_your_service_account_token"See the examples/ directory.
Basic - folder, data source, dashboard:
formae apply --mode reconcile --watch examples/basic/main.pklAlerting - contact points, mute timings, templates:
formae apply --mode reconcile --watch examples/alerting/main.pklObservability - LGTM stack via Docker Compose with Grafana dashboards provisioned through a target resolvable (requires formae >= 0.83.0 and formae-plugin-compose):
formae apply --mode reconcile --watch examples/observability/main.pklLicensed under FSL-1.1-ALv2. See LICENSE.