From c9e83f63482976d317adcf917260ef3ca3c55db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 16 Mar 2026 14:29:56 +0100 Subject: [PATCH 1/7] feat: create grafana component --- src/components/grafana/builder.ts | 35 ++++++++ src/components/grafana/grafana.ts | 136 ++++++++++++++++++++++++++++++ src/components/grafana/index.ts | 2 + 3 files changed, 173 insertions(+) create mode 100644 src/components/grafana/builder.ts create mode 100644 src/components/grafana/grafana.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts new file mode 100644 index 00000000..00a45124 --- /dev/null +++ b/src/components/grafana/builder.ts @@ -0,0 +1,35 @@ +import * as pulumi from '@pulumi/pulumi'; +import { Grafana } from './grafana'; + +export class GrafanaBuilder { + private name: string; + private prometheusConfig?: Grafana.PrometheusConfig; + private tags?: Grafana.Args['tags']; + + constructor(name: string) { + this.name = name; + } + + public withPrometheus(config: Grafana.PrometheusConfig): this { + this.prometheusConfig = config; + + return this; + } + + public withTags(tags: Grafana.Args['tags']): this { + this.tags = tags; + + return this; + } + + public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { + return new Grafana( + this.name, + { + prometheus: this.prometheusConfig, + tags: this.tags, + }, + opts, + ); + } +} diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts new file mode 100644 index 00000000..7b381c5f --- /dev/null +++ b/src/components/grafana/grafana.ts @@ -0,0 +1,136 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; + +const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; + +export namespace Grafana { + export type PrometheusConfig = { + prometheusEndpoint: pulumi.Input; + region: pulumi.Input; + }; + + export type Args = { + prometheus?: PrometheusConfig; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; + }; +} + +export class Grafana extends pulumi.ComponentResource { + prometheusDataSource?: grafana.oss.DataSource; + + constructor( + name: string, + args: Grafana.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:grafana:Grafana', name, {}, opts); + + if (args.prometheus) { + const ampRole = this.createAmpRole(name, args.tags); + this.createPrometheusDataSource(name, args.prometheus, ampRole); + } + + this.registerOutputs(); + } + + private getStackSlug(): string { + const grafanaUrl = process.env.GRAFANA_URL; + + if (!grafanaUrl) { + throw new Error('GRAFANA_URL environment variable is not set.'); + } + + return new URL(grafanaUrl).hostname.split('.')[0]; + } + + private createAmpRole(name: string, tags: Grafana.Args['tags']) { + const stackSlug = this.getStackSlug(); + const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); + + const ampRole = new aws.iam.Role( + `${name}-amp-role`, + { + assumeRolePolicy: pulumi.jsonStringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + AWS: `arn:aws:iam::${GRAFANA_CLOUD_AWS_ACCOUNT_ID}:root`, + }, + Action: 'sts:AssumeRole', + Condition: { + StringEquals: { + 'sts:ExternalId': pulumi.output(grafanaStack).id, + }, + }, + }, + ], + }), + tags, + }, + { parent: this }, + ); + + new aws.iam.RolePolicy( + `${name}-amp-policy`, + { + role: ampRole.id, + policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: [ + 'aps:GetSeries', + 'aps:GetLabels', + 'aps:GetMetricMetadata', + 'aps:QueryMetrics', + ], + Resource: '*', + }, + ], + }), + }, + { parent: this }, + ); + + return ampRole; + } + + private createPrometheusDataSource( + name: string, + config: Grafana.PrometheusConfig, + ampRole: aws.iam.Role, + ) { + const stackSlug = this.getStackSlug(); + + const plugin = new grafana.cloud.PluginInstallation( + `${name}-prometheus-plugin`, + { + stackSlug, + slug: 'grafana-amazonprometheus-datasource', + version: 'latest', + }, + { parent: this }, + ); + + this.prometheusDataSource = new grafana.oss.DataSource( + `${name}-prometheus-datasource`, + { + type: 'grafana-amazonprometheus-datasource', + url: config.prometheusEndpoint, + jsonDataEncoded: pulumi.jsonStringify({ + sigV4Auth: true, + sigV4AuthType: 'grafana_assume_role', + sigV4Region: config.region, + sigV4AssumeRoleArn: ampRole.arn, + }), + }, + { dependsOn: [plugin], parent: this }, + ); + } +} diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index e549b9ad..4009f0f7 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1 +1,3 @@ export * as dashboard from './dashboards'; +export { Grafana } from './grafana'; +export { GrafanaBuilder } from './builder'; From 1df213564f56ea0b42bb3d52033e0573216a45c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Mon, 16 Mar 2026 14:37:57 +0100 Subject: [PATCH 2/7] docs: add grafana aws account hardcoded value comment --- src/components/grafana/grafana.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 7b381c5f..e9b90c27 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -2,6 +2,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +// Fixed AWS account ID owned by Grafana Cloud, used to assume roles in customer accounts. const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; export namespace Grafana { From ff2d228bae4a62954b9aca116b2981d62d83fd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 17 Mar 2026 14:04:10 +0100 Subject: [PATCH 3/7] refactor: provider config value extraction --- src/components/grafana/grafana.ts | 71 ++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index e9b90c27..ef86707d 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -2,13 +2,14 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; -// Fixed AWS account ID owned by Grafana Cloud, used to assume roles in customer accounts. -const GRAFANA_CLOUD_AWS_ACCOUNT_ID = '008923505280'; +const awsConfig = new pulumi.Config('aws'); +const grafanaConfig = new pulumi.Config('grafana'); export namespace Grafana { export type PrometheusConfig = { prometheusEndpoint: pulumi.Input; - region: pulumi.Input; + region?: string; + prometheusPluginVersion?: string; }; export type Args = { @@ -20,6 +21,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { + grafanaIamRole: aws.iam.Role; prometheusDataSource?: grafana.oss.DataSource; constructor( @@ -29,30 +31,34 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); + this.grafanaIamRole = this.createGrafanaIamRole(name, args); + if (args.prometheus) { - const ampRole = this.createAmpRole(name, args.tags); - this.createPrometheusDataSource(name, args.prometheus, ampRole); + this.createAmpRolePolicy(name, this.grafanaIamRole); + this.createPrometheusDataSource( + name, + args.prometheus, + this.grafanaIamRole, + ); } this.registerOutputs(); } - private getStackSlug(): string { - const grafanaUrl = process.env.GRAFANA_URL; - - if (!grafanaUrl) { - throw new Error('GRAFANA_URL environment variable is not set.'); + private createGrafanaIamRole(name: string, args: Grafana.Args) { + const grafanaAwsAccountId = + grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; + if (!grafanaAwsAccountId) { + throw new Error( + 'Grafana AWS Account ID is not configured. Set it via Pulumi config (grafana:awsAccountId) or GRAFANA_AWS_ACCOUNT_ID env var.', + ); } - return new URL(grafanaUrl).hostname.split('.')[0]; - } - - private createAmpRole(name: string, tags: Grafana.Args['tags']) { const stackSlug = this.getStackSlug(); const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); - const ampRole = new aws.iam.Role( - `${name}-amp-role`, + const grafanaIamRole = new aws.iam.Role( + `${name}-grafana-iam-role`, { assumeRolePolicy: pulumi.jsonStringify({ Version: '2012-10-17', @@ -60,7 +66,7 @@ export class Grafana extends pulumi.ComponentResource { { Effect: 'Allow', Principal: { - AWS: `arn:aws:iam::${GRAFANA_CLOUD_AWS_ACCOUNT_ID}:root`, + AWS: `arn:aws:iam::${grafanaAwsAccountId}:root`, }, Action: 'sts:AssumeRole', Condition: { @@ -71,15 +77,31 @@ export class Grafana extends pulumi.ComponentResource { }, ], }), - tags, + tags: args.tags, }, { parent: this }, ); + return grafanaIamRole; + } + + private getStackSlug(): string { + const grafanaUrl = grafanaConfig.get('url') ?? process.env.GRAFANA_URL; + + if (!grafanaUrl) { + throw new Error( + 'Grafana URL is not configured. Set it via Pulumi config (grafana:url) or GRAFANA_URL env var.', + ); + } + + return new URL(grafanaUrl).hostname.split('.')[0]; + } + + private createAmpRolePolicy(name: string, grafanaIamRole: aws.iam.Role) { new aws.iam.RolePolicy( `${name}-amp-policy`, { - role: ampRole.id, + role: grafanaIamRole.id, policy: JSON.stringify({ Version: '2012-10-17', Statement: [ @@ -98,23 +120,22 @@ export class Grafana extends pulumi.ComponentResource { }, { parent: this }, ); - - return ampRole; } private createPrometheusDataSource( name: string, config: Grafana.PrometheusConfig, - ampRole: aws.iam.Role, + grafanaIamRole: aws.iam.Role, ) { const stackSlug = this.getStackSlug(); + const region = config.region ?? awsConfig.require('region'); const plugin = new grafana.cloud.PluginInstallation( `${name}-prometheus-plugin`, { stackSlug, slug: 'grafana-amazonprometheus-datasource', - version: 'latest', + version: config.prometheusPluginVersion ?? 'latest', }, { parent: this }, ); @@ -127,8 +148,8 @@ export class Grafana extends pulumi.ComponentResource { jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', - sigV4Region: config.region, - sigV4AssumeRoleArn: ampRole.arn, + sigV4Region: region, + sigV4AssumeRoleArn: grafanaIamRole.arn, }), }, { dependsOn: [plugin], parent: this }, From 02cb73428b8825433bcf468030a3b8ccc7e3544a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 17 Mar 2026 14:10:04 +0100 Subject: [PATCH 4/7] feat: remove tags prop from grafana builder and component --- src/components/grafana/builder.ts | 8 -------- src/components/grafana/grafana.ts | 6 ++---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 00a45124..07568849 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -4,7 +4,6 @@ import { Grafana } from './grafana'; export class GrafanaBuilder { private name: string; private prometheusConfig?: Grafana.PrometheusConfig; - private tags?: Grafana.Args['tags']; constructor(name: string) { this.name = name; @@ -16,18 +15,11 @@ export class GrafanaBuilder { return this; } - public withTags(tags: Grafana.Args['tags']): this { - this.tags = tags; - - return this; - } - public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { return new Grafana( this.name, { prometheus: this.prometheusConfig, - tags: this.tags, }, opts, ); diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index ef86707d..35d22a69 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -1,6 +1,7 @@ import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; +import { commonTags } from '../../shared/common-tags'; const awsConfig = new pulumi.Config('aws'); const grafanaConfig = new pulumi.Config('grafana'); @@ -14,9 +15,6 @@ export namespace Grafana { export type Args = { prometheus?: PrometheusConfig; - tags?: pulumi.Input<{ - [key: string]: pulumi.Input; - }>; }; } @@ -77,7 +75,7 @@ export class Grafana extends pulumi.ComponentResource { }, ], }), - tags: args.tags, + tags: commonTags, }, { parent: this }, ); From 452c85c2ee22565fdd9682d8dfcd52ed36dd27c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 11:03:29 +0100 Subject: [PATCH 5/7] refactor: rename grafana resources --- src/components/grafana/grafana.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 35d22a69..07c66b1a 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -10,7 +10,7 @@ export namespace Grafana { export type PrometheusConfig = { prometheusEndpoint: pulumi.Input; region?: string; - prometheusPluginVersion?: string; + pluginVersion?: string; }; export type Args = { @@ -133,14 +133,16 @@ export class Grafana extends pulumi.ComponentResource { { stackSlug, slug: 'grafana-amazonprometheus-datasource', - version: config.prometheusPluginVersion ?? 'latest', + version: config.pluginVersion ?? 'latest', }, { parent: this }, ); + const dataSourceName = `${name}-prometheus-datasource`; this.prometheusDataSource = new grafana.oss.DataSource( - `${name}-prometheus-datasource`, + dataSourceName, { + name: dataSourceName, type: 'grafana-amazonprometheus-datasource', url: config.prometheusEndpoint, jsonDataEncoded: pulumi.jsonStringify({ From 37e5dde676dda1512c6e1fe96baf7891a6f761e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 17:42:30 +0100 Subject: [PATCH 6/7] refactor: config naming --- src/components/grafana/builder.ts | 2 +- src/components/grafana/grafana.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 07568849..c3418aec 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -19,7 +19,7 @@ export class GrafanaBuilder { return new Grafana( this.name, { - prometheus: this.prometheusConfig, + prometheusConfig: this.prometheusConfig, }, opts, ); diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 07c66b1a..0b5143cd 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -8,13 +8,13 @@ const grafanaConfig = new pulumi.Config('grafana'); export namespace Grafana { export type PrometheusConfig = { - prometheusEndpoint: pulumi.Input; + endpoint: pulumi.Input; region?: string; pluginVersion?: string; }; export type Args = { - prometheus?: PrometheusConfig; + prometheusConfig?: PrometheusConfig; }; } @@ -31,11 +31,11 @@ export class Grafana extends pulumi.ComponentResource { this.grafanaIamRole = this.createGrafanaIamRole(name, args); - if (args.prometheus) { + if (args.prometheusConfig) { this.createAmpRolePolicy(name, this.grafanaIamRole); this.createPrometheusDataSource( name, - args.prometheus, + args.prometheusConfig, this.grafanaIamRole, ); } @@ -144,7 +144,7 @@ export class Grafana extends pulumi.ComponentResource { { name: dataSourceName, type: 'grafana-amazonprometheus-datasource', - url: config.prometheusEndpoint, + url: config.endpoint, jsonDataEncoded: pulumi.jsonStringify({ sigV4Auth: true, sigV4AuthType: 'grafana_assume_role', From 509db81561707d477a9920a69e3781ebe205e243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 18 Mar 2026 17:48:53 +0100 Subject: [PATCH 7/7] feat: add name public prop to grafana component --- src/components/grafana/grafana.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index 0b5143cd..92604e08 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -19,6 +19,7 @@ export namespace Grafana { } export class Grafana extends pulumi.ComponentResource { + name: string; grafanaIamRole: aws.iam.Role; prometheusDataSource?: grafana.oss.DataSource; @@ -29,12 +30,12 @@ export class Grafana extends pulumi.ComponentResource { ) { super('studion:grafana:Grafana', name, {}, opts); - this.grafanaIamRole = this.createGrafanaIamRole(name, args); + this.name = name; + this.grafanaIamRole = this.createGrafanaIamRole(); if (args.prometheusConfig) { - this.createAmpRolePolicy(name, this.grafanaIamRole); + this.createAmpRolePolicy(this.grafanaIamRole); this.createPrometheusDataSource( - name, args.prometheusConfig, this.grafanaIamRole, ); @@ -43,7 +44,7 @@ export class Grafana extends pulumi.ComponentResource { this.registerOutputs(); } - private createGrafanaIamRole(name: string, args: Grafana.Args) { + private createGrafanaIamRole() { const grafanaAwsAccountId = grafanaConfig.get('awsAccountId') ?? process.env.GRAFANA_AWS_ACCOUNT_ID; if (!grafanaAwsAccountId) { @@ -56,7 +57,7 @@ export class Grafana extends pulumi.ComponentResource { const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); const grafanaIamRole = new aws.iam.Role( - `${name}-grafana-iam-role`, + `${this.name}-grafana-iam-role`, { assumeRolePolicy: pulumi.jsonStringify({ Version: '2012-10-17', @@ -95,9 +96,9 @@ export class Grafana extends pulumi.ComponentResource { return new URL(grafanaUrl).hostname.split('.')[0]; } - private createAmpRolePolicy(name: string, grafanaIamRole: aws.iam.Role) { + private createAmpRolePolicy(grafanaIamRole: aws.iam.Role) { new aws.iam.RolePolicy( - `${name}-amp-policy`, + `${this.name}-amp-policy`, { role: grafanaIamRole.id, policy: JSON.stringify({ @@ -121,7 +122,6 @@ export class Grafana extends pulumi.ComponentResource { } private createPrometheusDataSource( - name: string, config: Grafana.PrometheusConfig, grafanaIamRole: aws.iam.Role, ) { @@ -129,7 +129,7 @@ export class Grafana extends pulumi.ComponentResource { const region = config.region ?? awsConfig.require('region'); const plugin = new grafana.cloud.PluginInstallation( - `${name}-prometheus-plugin`, + `${this.name}-prometheus-plugin`, { stackSlug, slug: 'grafana-amazonprometheus-datasource', @@ -138,7 +138,7 @@ export class Grafana extends pulumi.ComponentResource { { parent: this }, ); - const dataSourceName = `${name}-prometheus-datasource`; + const dataSourceName = `${this.name}-prometheus-datasource`; this.prometheusDataSource = new grafana.oss.DataSource( dataSourceName, {