Skip to content

Commit 30d030c

Browse files
authored
feat: grafana component (#183)
To instantiate the Grafana component, the following config or env variables need to be set: `GRAFANA_URL`, `GRAFANA_AUTH`, `GRAFANA_CLOUD_ACCESS_POLICY_TOKEN`, `GRAFANA_AWS_ACCOUNT_ID`.
1 parent 1e438dc commit 30d030c

6 files changed

Lines changed: 272 additions & 0 deletions

File tree

src/components/grafana/builder.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as pulumi from '@pulumi/pulumi';
2+
import { AMPConnection, GrafanaConnection } from './connections';
3+
import { Grafana } from './grafana';
4+
5+
export class GrafanaBuilder {
6+
private readonly name: string;
7+
private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] =
8+
[];
9+
10+
constructor(name: string) {
11+
this.name = name;
12+
}
13+
14+
public addAmp(name: string, args: AMPConnection.Args): this {
15+
this.connectionBuilders.push(opts => new AMPConnection(name, args, opts));
16+
17+
return this;
18+
}
19+
20+
public addConnection(builder: GrafanaConnection.ConnectionBuilder): this {
21+
this.connectionBuilders.push(builder);
22+
23+
return this;
24+
}
25+
26+
public build(opts: pulumi.ComponentResourceOptions = {}): Grafana {
27+
if (!this.connectionBuilders.length) {
28+
throw new Error(
29+
'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.',
30+
);
31+
}
32+
33+
return new Grafana(
34+
this.name,
35+
{
36+
connectionBuilders: this.connectionBuilders,
37+
},
38+
opts,
39+
);
40+
}
41+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as aws from '@pulumi/aws';
2+
import * as pulumi from '@pulumi/pulumi';
3+
import * as grafana from '@pulumiverse/grafana';
4+
import { mergeWithDefaults } from '../../../shared/merge-with-defaults';
5+
import { GrafanaConnection } from './connection';
6+
7+
const awsConfig = new pulumi.Config('aws');
8+
const pluginName = 'grafana-amazonprometheus-datasource';
9+
10+
export namespace AMPConnection {
11+
export type Args = GrafanaConnection.Args & {
12+
endpoint: pulumi.Input<string>;
13+
region?: string;
14+
pluginVersion?: string;
15+
};
16+
}
17+
18+
const defaults = {
19+
pluginVersion: 'latest',
20+
region: awsConfig.require('region'),
21+
};
22+
23+
export class AMPConnection extends GrafanaConnection {
24+
public readonly name: string;
25+
public readonly dataSource: grafana.oss.DataSource;
26+
public readonly plugin: grafana.cloud.PluginInstallation;
27+
public readonly rolePolicy: aws.iam.RolePolicy;
28+
29+
constructor(
30+
name: string,
31+
args: AMPConnection.Args,
32+
opts: pulumi.ComponentResourceOptions = {},
33+
) {
34+
super('studion:grafana:AMPConnection', name, args, opts);
35+
36+
const argsWithDefaults = mergeWithDefaults(defaults, args);
37+
38+
this.name = name;
39+
40+
this.rolePolicy = this.createRolePolicy();
41+
this.plugin = this.createPlugin(argsWithDefaults.pluginVersion);
42+
this.dataSource = this.createDataSource(
43+
argsWithDefaults.region,
44+
argsWithDefaults.endpoint,
45+
);
46+
47+
this.registerOutputs();
48+
}
49+
50+
private createRolePolicy(): aws.iam.RolePolicy {
51+
const policy = aws.iam.getPolicyDocumentOutput({
52+
statements: [
53+
{
54+
effect: 'Allow',
55+
actions: [
56+
'aps:GetSeries',
57+
'aps:GetLabels',
58+
'aps:GetMetricMetadata',
59+
'aps:QueryMetrics',
60+
],
61+
resources: ['*'],
62+
},
63+
],
64+
});
65+
66+
return new aws.iam.RolePolicy(
67+
`${this.name}-amp-policy`,
68+
{
69+
role: this.role.id,
70+
policy: policy.json,
71+
},
72+
{ parent: this },
73+
);
74+
}
75+
76+
private createPlugin(
77+
pluginVersion: string,
78+
): grafana.cloud.PluginInstallation {
79+
return new grafana.cloud.PluginInstallation(
80+
`${this.name}-amp-plugin`,
81+
{
82+
stackSlug: this.getStackSlug(),
83+
slug: pluginName,
84+
version: pluginVersion,
85+
},
86+
{ parent: this },
87+
);
88+
}
89+
90+
private createDataSource(
91+
region: string,
92+
endpoint: AMPConnection.Args['endpoint'],
93+
): grafana.oss.DataSource {
94+
const dataSourceName = `${this.name}-amp-datasource`;
95+
96+
return new grafana.oss.DataSource(
97+
dataSourceName,
98+
{
99+
name: dataSourceName,
100+
type: pluginName,
101+
url: endpoint,
102+
jsonDataEncoded: pulumi.jsonStringify({
103+
sigV4Auth: true,
104+
sigV4AuthType: 'grafana_assume_role',
105+
sigV4Region: region,
106+
sigV4AssumeRoleArn: this.role.arn,
107+
}),
108+
},
109+
{ dependsOn: [this.plugin], parent: this },
110+
);
111+
}
112+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as aws from '@pulumi/aws';
2+
import * as pulumi from '@pulumi/pulumi';
3+
import * as grafana from '@pulumiverse/grafana';
4+
import { commonTags } from '../../../shared/common-tags';
5+
6+
const grafanaConfig = new pulumi.Config('grafana');
7+
8+
export namespace GrafanaConnection {
9+
export type Args = {
10+
awsAccountId: string;
11+
};
12+
13+
export type ConnectionBuilder = (
14+
opts: pulumi.ComponentResourceOptions,
15+
) => GrafanaConnection;
16+
}
17+
18+
export abstract class GrafanaConnection extends pulumi.ComponentResource {
19+
public readonly name: string;
20+
public readonly role: aws.iam.Role;
21+
public abstract readonly dataSource: grafana.oss.DataSource;
22+
23+
constructor(
24+
type: string,
25+
name: string,
26+
args: GrafanaConnection.Args,
27+
opts: pulumi.ComponentResourceOptions = {},
28+
) {
29+
super(type, name, {}, opts);
30+
31+
this.name = name;
32+
33+
this.role = this.createIamRole(args.awsAccountId);
34+
35+
this.registerOutputs();
36+
}
37+
38+
protected getStackSlug(): string {
39+
const grafanaUrl = grafanaConfig.get('url') ?? process.env.GRAFANA_URL;
40+
41+
if (!grafanaUrl) {
42+
throw new Error(
43+
'Grafana URL is not configured. Set it via Pulumi config (grafana:url) or GRAFANA_URL env var.',
44+
);
45+
}
46+
47+
return new URL(grafanaUrl).hostname.split('.')[0];
48+
}
49+
50+
private createIamRole(awsAccountId: string): aws.iam.Role {
51+
const stackSlug = this.getStackSlug();
52+
const grafanaStack = grafana.cloud.getStack({ slug: stackSlug });
53+
54+
const assumeRolePolicy = aws.iam.getPolicyDocumentOutput({
55+
statements: [
56+
{
57+
effect: 'Allow',
58+
principals: [
59+
{
60+
type: 'AWS',
61+
identifiers: [`arn:aws:iam::${awsAccountId}:root`],
62+
},
63+
],
64+
actions: ['sts:AssumeRole'],
65+
conditions: [
66+
{
67+
test: 'StringEquals',
68+
variable: 'sts:ExternalId',
69+
values: [pulumi.output(grafanaStack).id],
70+
},
71+
],
72+
},
73+
],
74+
});
75+
76+
return new aws.iam.Role(
77+
`${this.name}-grafana-iam-role`,
78+
{
79+
assumeRolePolicy: assumeRolePolicy.json,
80+
tags: commonTags,
81+
},
82+
{ parent: this },
83+
);
84+
}
85+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { GrafanaConnection } from './connection';
2+
export { AMPConnection } from './amp-connection';

src/components/grafana/grafana.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as pulumi from '@pulumi/pulumi';
2+
import { GrafanaConnection } from './connections';
3+
4+
export namespace Grafana {
5+
export type Args = {
6+
connectionBuilders: GrafanaConnection.ConnectionBuilder[];
7+
};
8+
}
9+
10+
export class Grafana extends pulumi.ComponentResource {
11+
public readonly name: string;
12+
public readonly connections: GrafanaConnection[];
13+
14+
constructor(
15+
name: string,
16+
args: Grafana.Args,
17+
opts: pulumi.ComponentResourceOptions = {},
18+
) {
19+
super('studion:grafana:Grafana', name, {}, opts);
20+
21+
this.name = name;
22+
23+
this.connections = args.connectionBuilders.map(build =>
24+
build({ parent: this }),
25+
);
26+
27+
this.registerOutputs();
28+
}
29+
}

src/components/grafana/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
export * as dashboard from './dashboards';
2+
export { GrafanaConnection, AMPConnection } from './connections';
3+
export { Grafana } from './grafana';
4+
export { GrafanaBuilder } from './builder';

0 commit comments

Comments
 (0)