Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
28 changes: 28 additions & 0 deletions charts/rhesis/values-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ postgresql:
secretKeyRef:
name: rhesis-app-secrets
key: ANALYTICS_DB_PASS
- name: GRAFANA_VIEWER_PASSWORD
valueFrom:
secretKeyRef:
name: rhesis-app-secrets
key: GRAFANA_VIEWER_PASSWORD
persistence:
enabled: true
size: 5Gi
Expand Down Expand Up @@ -352,6 +357,29 @@ postgresql:
ALTER DEFAULT PRIVILEGES FOR ROLE "rhesis-admin" IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO "rhesis-user";
EOSQL
init-grafana-viewer.sh: |
#!/bin/bash
# Read-only role behind the Grafana Postgres datasource. Mirrors stg/prd CNPG
# managed.roles + postInitApplicationSQL grants.
# BYPASSRLS is required, not convenience: `usage` is FORCE ROW LEVEL SECURITY and
# Grafana never sets app.current_organization, so without it every cross-org
# dashboard query returns zero rows. Same reasoning as PRs #2422 and #2437.
# Runs as rhesis-admin during first-time PVC init only.
set -e
psql -v ON_ERROR_STOP=1 -v grafana_viewer_pass="$GRAFANA_VIEWER_PASSWORD" \
--username "$POSTGRES_USER" --dbname "postgres" <<'EOSQL'
CREATE ROLE "grafana-viewer" LOGIN BYPASSRLS PASSWORD :'grafana_viewer_pass';
EOSQL
psql -v ON_ERROR_STOP=1 -v postgres_db="$POSTGRES_DB" \
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'EOSQL'
GRANT CONNECT ON DATABASE :"postgres_db" TO "grafana-viewer";
GRANT USAGE ON SCHEMA public TO "grafana-viewer";
GRANT SELECT ON ALL TABLES IN SCHEMA public TO "grafana-viewer";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Improvement grafana-viewer gets GRANT SELECT ON ALL TABLES IN SCHEMA public + default privileges for future tables.

That’s broader than this dashboard needs (usage + organization) and, combined with BYPASSRLS, means a Grafana compromise can read any table in public across all tenants.

Fix: if feasible, scope privileges to the minimal set (e.g. GRANT SELECT ON TABLE public.usage, public.organization TO "grafana-viewer"; and avoid granting default privileges broadly), or document explicitly why full-schema read is required.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Confirmed this matches the existing stg/prd pattern (CNPG postInitApplicationSQL also grants SELECT ON ALL TABLES IN SCHEMA public + default privileges).

Still worth calling out as a conscious tradeoff: BYPASSRLS + full-schema SELECT means a Grafana compromise can read any tenant data in public. If scoping isn’t feasible operationally (because usage is created later), maybe add an explicit comment in the script explaining why the broader grant is intentional.

-- No app tables exist yet at initdb, so ALL TABLES above is a no-op and this
-- is what actually covers `usage`: Alembic creates it later as rhesis-admin.
ALTER DEFAULT PRIVILEGES FOR ROLE "rhesis-admin" IN SCHEMA public
GRANT SELECT ON TABLES TO "grafana-viewer";
EOSQL
podSecurityContext:
enabled: true
fsGroup: 999
Expand Down
1 change: 1 addition & 0 deletions infrastructure/config/gsm-secrets.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"GOOGLE_APPLICATION_CREDENTIALS": "<BASE64_ENCODED_SERVICE_ACCOUNT_JSON>",
"SENDGRID_API_KEY": "<SENDGRID_API_KEY>",
"GF_SECURITY_ADMIN_PASSWORD": "<GRAFANA_ADMIN_PASSWORD>",
"GRAFANA_VIEWER_PASSWORD": "<GRAFANA_VIEWER_PASSWORD>",
"AUDIT_HASH_KEY": "<AUDIT_HASH_KEY>"
},
"stg": {
Expand Down
2 changes: 2 additions & 0 deletions kubernetes/base/grafana-resources/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ resources:
- grafana-ingress.yaml
- prometheus-datasource.yaml
- loki-datasource.yaml
# Picks its Postgres datasource by template variable, so one copy works in every env.
- usage-dashboard.yaml
263 changes: 263 additions & 0 deletions kubernetes/base/grafana-resources/usage-dashboard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
# Cross-org usage dashboard, read straight from the `usage` table via the per-env
# read-only Postgres datasource. The in-app Usage page shows one org its own numbers;
# this shows the whole platform.
#
# Lives in base/ (not per-cluster) even though each env's datasource has a different
# name, because the datasource is picked by a template variable rather than hardcoded.
# The variable queries type "postgres", which is the `type` all three GrafanaDatasource
# CRs declare, so it matches whether or not this Grafana resolves the newer
# grafana-postgresql-datasource plugin alias.
#
# Periods are UTC-anchored calendar months (services/usage.py:_current_period), pinned at
# both ends: `timezone: utc` so rendering does not shift rows across month boundaries, and
# `now() AT TIME ZONE 'UTC'` in SQL so the month bounds do not depend on the DB session
# TimeZone, which no CNPG manifest sets.
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: rhesis-usage
namespace: monitoring
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
folder: Rhesis
instanceSelector:
matchLabels:
dashboards: grafana
json: |
{
"uid": "rhesis-usage",
"title": "Rhesis Usage",
"tags": ["rhesis", "usage"],
"timezone": "utc",
"editable": false,
"schemaVersion": 39,
"version": 1,
"refresh": "",
"time": { "from": "now-12M", "to": "now" },
"templating": {
"list": [
{
"name": "datasource",
"label": "Datasource",
"type": "datasource",
"query": "postgres",
"current": {},
"hide": 0
},
{
"name": "resource",
"label": "Resource",
"type": "query",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"query": "SELECT DISTINCT resource FROM public.usage WHERE deleted_at IS NULL ORDER BY 1",
"refresh": 1,
"sort": 1,
"current": {},
"hide": 0
},
{
"name": "org",
"label": "Organization",
"type": "query",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"query": "SELECT COALESCE(display_name, name) FROM public.organization WHERE deleted_at IS NULL ORDER BY 1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Improvement Org variable uses COALESCE(display_name, name) as the value.

If two orgs share a display name/name (or a name contains quotes), series can merge and the IN ($org) filter can get tricky.

Fix: return ids + labels (Grafana convention SELECT id AS __value, COALESCE(display_name,name) AS __text ...), and filter/group by o.id (e.g. o.id IN (${org:csv})) while still displaying the name in the legend.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One extra reason to switch the org variable/value to ids: several panels also GROUP BY / filter on COALESCE(o.display_name, o.name), so two orgs sharing a display_name would get merged into one series/row.

Using SELECT id AS __value, COALESCE(display_name,name) AS __text ... and grouping/filtering by o.id keeps the labels nice while guaranteeing correctness.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed now: org templating query returns id AS __value + __text, so filters/grouping can safely key on o.id without name collisions.

"refresh": 1,
"sort": 1,
"multi": true,
"includeAll": true,
"current": { "text": "All", "value": "$__all" },
"hide": 0
}
]
},
"panels": [
{
"type": "row",
"title": "Current billing period",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"collapsed": false,
"panels": []
},
{
"id": 1,
"type": "stat",
"title": "Platform total this month",
"description": "Sum across every organization for the current calendar month. Ignores the time picker.",
"gridPos": { "h": 4, "w": 24, "x": 0, "y": 1 },
"fieldConfig": {
"defaults": { "unit": "short", "decimals": 0, "thresholds": { "mode": "absolute", "steps": [{ "color": "text", "value": null }] } },
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"colorMode": "none",
"graphMode": "none",
"textMode": "auto",
"justifyMode": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "table",
"rawSql": "SELECT\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_executions'), 0) AS \"Test executions\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'tracing_spans'), 0) AS \"Tracing spans\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_generation'), 0) AS \"Tests generated\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'model_tokens'), 0) AS \"Model tokens\"\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.period_start = date_trunc('month', now() AT TIME ZONE 'UTC')::date"
}
]
},
{
"id": 2,
"type": "table",
"title": "Usage by organization, this month",
"description": "One row per organization for the current calendar month. Ignores the time picker.",
"gridPos": { "h": 9, "w": 16, "x": 0, "y": 5 },
"fieldConfig": {
"defaults": { "unit": "short", "decimals": 0, "custom": { "align": "auto", "filterable": true } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Organization" }, "properties": [{ "id": "custom.width", "value": 260 }] }
]
},
"options": { "showHeader": true, "footer": { "show": true, "reducer": ["sum"], "fields": "" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "table",
"rawSql": "SELECT\n COALESCE(o.display_name, o.name) AS \"Organization\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_executions'), 0) AS \"Test executions\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'tracing_spans'), 0) AS \"Tracing spans\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_generation'), 0) AS \"Tests generated\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'model_tokens'), 0) AS \"Model tokens\"\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.period_start = date_trunc('month', now() AT TIME ZONE 'UTC')::date\nGROUP BY 1\nORDER BY 2 DESC, 1"
}
]
},
{
"id": 3,
"type": "bargauge",
"title": "Top consumers: $resource",
"description": "Highest ten organizations for the selected resource, current calendar month.",
"gridPos": { "h": 9, "w": 8, "x": 16, "y": 5 },
"fieldConfig": {
"defaults": { "unit": "short", "decimals": 0, "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } },
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": true },
"displayMode": "gradient",
"orientation": "horizontal",
"showUnfilled": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "table",
"rawSql": "SELECT\n COALESCE(o.display_name, o.name) AS org,\n SUM(u.used) AS used\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.resource = '$resource'\n AND u.period_start = date_trunc('month', now() AT TIME ZONE 'UTC')::date\nGROUP BY 1\nHAVING SUM(u.used) > 0\nORDER BY 2 DESC\nLIMIT 10"
}
]
},
{
"type": "row",
"title": "Trends",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
"collapsed": false,
"panels": []
},
{
"id": 4,
"type": "timeseries",
"title": "Platform total by month",
"description": "One point per calendar month, so a time range shorter than a month renders empty. Default range is 12 months.",
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 15 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1, "barAlignment": 0, "showPoints": "never", "stacking": { "mode": "none" } }
},
"overrides": []
},
"options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "time_series",
"rawSql": "SELECT\n u.period_start::timestamptz AS \"time\",\n u.resource AS metric,\n SUM(u.used) AS value\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND $__timeFilter(u.period_start)\nGROUP BY 1, 2\nORDER BY 1"
}
]
},
{
"id": 5,
"type": "timeseries",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Improvement Trends panel casts u.period_start::timestamptz.

period_start is a date; casting to timestamptz interprets midnight in the DB session timezone, which can shift the plotted timestamp away from the intended UTC month boundary.

Fix: anchor as UTC, e.g. u.period_start::timestamp AT TIME ZONE 'UTC' AS "time" (and apply $__timeFilter to the same expression).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: period_start is now anchored as UTC (::timestamp AT TIME ZONE 'UTC').

Same minor note as above: consider changing AND $__timeFilter(u.period_start) to AND $__timeFilter(u.period_start::timestamp AT TIME ZONE 'UTC') so the filter uses the same timezone/type as the plotted time.

"title": "$resource by organization",
"description": "One point per calendar month for the selected resource and organizations.",
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 15 },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "always", "spanNulls": true }
},
"overrides": []
},
"options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "time_series",
"rawSql": "SELECT\n u.period_start::timestamptz AS \"time\",\n COALESCE(o.display_name, o.name) AS metric,\n SUM(u.used) AS value\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.resource = '$resource'\n AND COALESCE(o.display_name, o.name) IN (${org:sqlstring})\n AND $__timeFilter(u.period_start)\nGROUP BY 1, 2\nORDER BY 1"
}
]
},
{
"id": 6,
"type": "table",
"title": "Month over month: $resource",
"description": "This calendar month against the previous one for the selected resource. Ignores the time picker.",
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 24 },
"fieldConfig": {
"defaults": { "unit": "short", "decimals": 0, "custom": { "align": "auto", "filterable": true } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Organization" }, "properties": [{ "id": "custom.width", "value": 260 }] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Improvement Org filter/groups use COALESCE(o.display_name, o.name).

If two orgs share a display name (or a name changes), series/rows can merge and filtering becomes ambiguous.

Fix: switch the org template var to ids (SELECT id AS __value, COALESCE(display_name,name) AS __text ...) and filter/group by o.id (while still using the name only for display/legend).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is addressed now that the org variable is ID-valued and the panels group by o.id / filter on o.id (vs grouping/filtering by COALESCE(...)).

{ "matcher": { "id": "byName", "options": "Change" }, "properties": [{ "id": "custom.cellOptions", "value": { "type": "color-text" } }, { "id": "color", "value": { "mode": "continuous-RdYlGr" } }] }
]
},
"options": { "showHeader": true, "sortBy": [{ "displayName": "Change", "desc": true }] },
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"editorMode": "code",
"rawQuery": true,
"format": "table",
"rawSql": "WITH bounds AS (\n SELECT\n date_trunc('month', now() AT TIME ZONE 'UTC')::date AS this_month,\n (date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 month')::date AS prev_month\n),\nmonthly AS (\n SELECT\n COALESCE(o.display_name, o.name) AS org,\n u.period_start,\n SUM(u.used) AS used\n FROM public.usage u\n JOIN public.organization o ON o.id = u.organization_id\n CROSS JOIN bounds b\n WHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.resource = '$resource'\n AND u.period_start IN (b.prev_month, b.this_month)\n GROUP BY 1, 2\n)\nSELECT\n m.org AS \"Organization\",\n COALESCE(SUM(m.used) FILTER (WHERE m.period_start = b.prev_month), 0) AS \"Previous month\",\n COALESCE(SUM(m.used) FILTER (WHERE m.period_start = b.this_month), 0) AS \"This month\",\n COALESCE(SUM(m.used) FILTER (WHERE m.period_start = b.this_month), 0)\n - COALESCE(SUM(m.used) FILTER (WHERE m.period_start = b.prev_month), 0) AS \"Change\"\nFROM monthly m\nCROSS JOIN bounds b\nGROUP BY 1\nORDER BY 4 DESC"
}
]
},
{
"type": "row",
"title": "Notes",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 },
"collapsed": false,
"panels": []
},
{
"id": 7,
"type": "text",
"title": "What is and is not here",
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 34 },
"options": {
"mode": "markdown",
"content": "**Limits are not shown.** Each tier's quota lives in the EE tier config and the org's signed license, neither of which is readable from SQL. An org's used-vs-limit view is on its own Usage page in the app.\n\n**seats, projects and endpoints are missing on purpose.** They are counted live off the `user`, `project` and `endpoint` tables rather than accrued into `usage`, so they have no history to chart.\n\n**Periods are UTC calendar months.** Each row covers one month, so panels above either pin to the current month or need a range of a month or more."
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ spec:
# dev-rhesis-admin-db-pass -> ADMIN_DB_PASS (required when ADMIN_DB_USER is set)
# dev-rhesis-db-host -> DB_HOST (e.g. "/cloudsql/project:region:instance")
# dev-rhesis-db-name -> DB_NAME (e.g. "rhesis-db")
# dev-grafana-viewer-password -> GRAFANA_VIEWER_PASSWORD (read-only role for Grafana)
# The old dev-rhesis-db-pass and dev-rhesis-sqlalchemy-database-url entries
# can be removed once all workloads are updated.
# -----------------------------------------------------------------------
Expand Down Expand Up @@ -73,6 +74,11 @@ spec:
remoteRef:
key: dev-rhesis-db-name

# Consumed by the postgres subchart's init-grafana-viewer.sh, not by any app.
- secretKey: GRAFANA_VIEWER_PASSWORD
remoteRef:
key: dev-grafana-viewer-password

- secretKey: DB_ENCRYPTION_KEY
remoteRef:
key: dev-rhesis-db-encryption-key
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Same GSM key as the GRAFANA_VIEWER_PASS entry in external-secrets/rhesis-app-secrets.yaml,
# synced separately into monitoring so the GrafanaDatasource CR in this namespace can read it.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: grafana-viewer-credentials
namespace: monitoring
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: grafana-viewer-credentials
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: dev-grafana-viewer-password
4 changes: 4 additions & 0 deletions kubernetes/clusters/dev/grafana-resources/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ kind: Kustomization

resources:
- ../../../base/grafana-resources
# dev-only: reads a dev-scoped GSM secret and the Bitnami subchart's service name, so
# this doesn't belong in base/grafana-resources (shared identically by dev/stg/prd).
- grafana-viewer-credentials.yaml
- postgres-dev-datasource.yaml

configMapGenerator:
- name: grafana-config
Expand Down
Loading
Loading