diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts new file mode 100644 index 00000000..9e07939e --- /dev/null +++ b/src/components/grafana/builder.ts @@ -0,0 +1,41 @@ +import * as pulumi from '@pulumi/pulumi'; +import { AMPConnection, GrafanaConnection } from './connections'; +import { Grafana } from './grafana'; + +export class GrafanaBuilder { + private readonly name: string; + private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = + []; + + constructor(name: string) { + this.name = name; + } + + public addAmp(name: string, args: AMPConnection.Args): this { + this.connectionBuilders.push(opts => new AMPConnection(name, args, opts)); + + return this; + } + + public addConnection(builder: GrafanaConnection.ConnectionBuilder): this { + this.connectionBuilders.push(builder); + + return this; + } + + public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { + if (!this.connectionBuilders.length) { + throw new Error( + 'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.', + ); + } + + return new Grafana( + this.name, + { + connectionBuilders: this.connectionBuilders, + }, + opts, + ); + } +} diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts new file mode 100644 index 00000000..1afd0240 --- /dev/null +++ b/src/components/grafana/connections/amp-connection.ts @@ -0,0 +1,112 @@ +import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; +import { GrafanaConnection } from './connection'; + +const awsConfig = new pulumi.Config('aws'); +const pluginName = 'grafana-amazonprometheus-datasource'; + +export namespace AMPConnection { + export type Args = GrafanaConnection.Args & { + endpoint: pulumi.Input; + region?: string; + pluginVersion?: string; + }; +} + +const defaults = { + pluginVersion: 'latest', + region: awsConfig.require('region'), +}; + +export class AMPConnection extends GrafanaConnection { + public readonly name: string; + public readonly dataSource: grafana.oss.DataSource; + public readonly plugin: grafana.cloud.PluginInstallation; + public readonly rolePolicy: aws.iam.RolePolicy; + + constructor( + name: string, + args: AMPConnection.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:grafana:AMPConnection', name, args, opts); + + const argsWithDefaults = mergeWithDefaults(defaults, args); + + this.name = name; + + this.rolePolicy = this.createRolePolicy(); + this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + this.dataSource = this.createDataSource( + argsWithDefaults.region, + argsWithDefaults.endpoint, + ); + + this.registerOutputs(); + } + + private createRolePolicy(): aws.iam.RolePolicy { + const policy = aws.iam.getPolicyDocumentOutput({ + statements: [ + { + effect: 'Allow', + actions: [ + 'aps:GetSeries', + 'aps:GetLabels', + 'aps:GetMetricMetadata', + 'aps:QueryMetrics', + ], + resources: ['*'], + }, + ], + }); + + return new aws.iam.RolePolicy( + `${this.name}-amp-policy`, + { + role: this.role.id, + policy: policy.json, + }, + { parent: this }, + ); + } + + private createPlugin( + pluginVersion: string, + ): grafana.cloud.PluginInstallation { + return new grafana.cloud.PluginInstallation( + `${this.name}-amp-plugin`, + { + stackSlug: this.getStackSlug(), + slug: pluginName, + version: pluginVersion, + }, + { parent: this }, + ); + } + + private createDataSource( + region: string, + endpoint: AMPConnection.Args['endpoint'], + ): grafana.oss.DataSource { + const dataSourceName = `${this.name}-amp-datasource`; + + return new grafana.oss.DataSource( + dataSourceName, + { + name: dataSourceName, + type: pluginName, + url: endpoint, + jsonDataEncoded: pulumi.jsonStringify({ + sigV4Auth: true, + sigV4AuthType: 'grafana_assume_role', + sigV4Region: region, + sigV4AssumeRoleArn: this.role.arn, + }), + }, + { dependsOn: [this.plugin], parent: this }, + ); + } +} diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts new file mode 100644 index 00000000..98efc36a --- /dev/null +++ b/src/components/grafana/connections/connection.ts @@ -0,0 +1,85 @@ +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 grafanaConfig = new pulumi.Config('grafana'); + +export namespace GrafanaConnection { + export type Args = { + awsAccountId: string; + }; + + export type ConnectionBuilder = ( + opts: pulumi.ComponentResourceOptions, + ) => GrafanaConnection; +} + +export abstract class GrafanaConnection extends pulumi.ComponentResource { + public readonly name: string; + public readonly role: aws.iam.Role; + public abstract readonly dataSource: grafana.oss.DataSource; + + constructor( + type: string, + name: string, + args: GrafanaConnection.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super(type, name, {}, opts); + + this.name = name; + + this.role = this.createIamRole(args.awsAccountId); + + this.registerOutputs(); + } + + protected 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 createIamRole(awsAccountId: string): aws.iam.Role { + const stackSlug = this.getStackSlug(); + const grafanaStack = grafana.cloud.getStack({ slug: stackSlug }); + + const assumeRolePolicy = aws.iam.getPolicyDocumentOutput({ + statements: [ + { + effect: 'Allow', + principals: [ + { + type: 'AWS', + identifiers: [`arn:aws:iam::${awsAccountId}:root`], + }, + ], + actions: ['sts:AssumeRole'], + conditions: [ + { + test: 'StringEquals', + variable: 'sts:ExternalId', + values: [pulumi.output(grafanaStack).id], + }, + ], + }, + ], + }); + + return new aws.iam.Role( + `${this.name}-grafana-iam-role`, + { + assumeRolePolicy: assumeRolePolicy.json, + tags: commonTags, + }, + { parent: this }, + ); + } +} diff --git a/src/components/grafana/connections/index.ts b/src/components/grafana/connections/index.ts new file mode 100644 index 00000000..8cf0d756 --- /dev/null +++ b/src/components/grafana/connections/index.ts @@ -0,0 +1,2 @@ +export { GrafanaConnection } from './connection'; +export { AMPConnection } from './amp-connection'; diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts new file mode 100644 index 00000000..a81bdf5d --- /dev/null +++ b/src/components/grafana/grafana.ts @@ -0,0 +1,29 @@ +import * as pulumi from '@pulumi/pulumi'; +import { GrafanaConnection } from './connections'; + +export namespace Grafana { + export type Args = { + connectionBuilders: GrafanaConnection.ConnectionBuilder[]; + }; +} + +export class Grafana extends pulumi.ComponentResource { + public readonly name: string; + public readonly connections: GrafanaConnection[]; + + constructor( + name: string, + args: Grafana.Args, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:grafana:Grafana', name, {}, opts); + + this.name = name; + + this.connections = args.connectionBuilders.map(build => + build({ parent: this }), + ); + + this.registerOutputs(); + } +} diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index e549b9ad..ab5c8869 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1 +1,4 @@ export * as dashboard from './dashboards'; +export { GrafanaConnection, AMPConnection } from './connections'; +export { Grafana } from './grafana'; +export { GrafanaBuilder } from './builder';