Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c9e83f6
feat: create grafana component
bornast Mar 16, 2026
1df2135
docs: add grafana aws account hardcoded value comment
bornast Mar 16, 2026
ff2d228
refactor: provider config value extraction
bornast Mar 17, 2026
02cb734
feat: remove tags prop from grafana builder and component
bornast Mar 17, 2026
452c85c
refactor: rename grafana resources
bornast Mar 18, 2026
fd465d2
feat: grafana addDashboard builder method
bornast Mar 18, 2026
37e5dde
refactor: config naming
bornast Mar 18, 2026
60d26c2
Merge branch 'feat/grafana-comp' into feat/grafana-dashboards
bornast Mar 18, 2026
509db81
feat: add name public prop to grafana component
bornast Mar 18, 2026
fd8dd9d
Merge branch 'feat/grafana-comp' into feat/grafana-dashboards
bornast Mar 18, 2026
4d0839d
feat: grafana tests
bornast Mar 19, 2026
f8918f3
test: add custom panel test
bornast Mar 19, 2026
29e7167
feat: introduce grafana connections
bornast Mar 19, 2026
9aced94
refactor: remove unnecessary lines
bornast Mar 19, 2026
b766efe
refactor: method signatures
bornast Mar 19, 2026
99376d5
feat: make grafana props readonly
bornast Mar 19, 2026
50398f2
Merge branch 'feat/grafana-comp' into feat/grafana-dashboards
bornast Mar 19, 2026
631ded8
feat: add name prop to grafana component
bornast Mar 19, 2026
8cec815
refactor: method signatures
bornast Mar 25, 2026
1626551
refactor: method signatures
bornast Mar 25, 2026
075cad6
Merge branch 'feat/grafana-comp' into feat/grafana-dashboards
bornast Mar 26, 2026
fa0dd95
feat: generic dashboard builder
bornast Mar 27, 2026
d07403c
Merge branch 'master' into feat/grafana-dashboards
bornast Mar 27, 2026
6a58b75
Merge branch 'master' into feat/grafana-dashboards
bornast Mar 30, 2026
fde5f48
refactor: dashboard build configuration
bornast Mar 30, 2026
21a9b46
refactor: dashboard builder
bornast Mar 30, 2026
4d46c9b
feat: add dashboard builder default configuration
bornast Mar 30, 2026
813775e
refactor: panel export type
bornast Mar 30, 2026
a389874
refactor: panel types
bornast Mar 30, 2026
0c6d123
refactor: rename prometheusNamespace to ampNamespace
bornast Mar 30, 2026
410920d
Merge branch 'feat/grafana-dashboards' into test/grafana
bornast Mar 30, 2026
6414b67
fix: test configuration
bornast Mar 30, 2026
e522d6f
refactor: panel types
bornast Mar 30, 2026
36dac57
Merge branch 'master' into feat/grafana-dashboards
bornast Mar 30, 2026
3049a67
refactor: error msg
bornast Mar 30, 2026
518e77c
refactor: rename connection and dashboard creation methods
bornast Mar 31, 2026
7f56ddf
Merge branch 'master' into feat/grafana-dashboards
bornast Mar 31, 2026
5598b23
feat: add grafana folderName parameter
bornast Mar 31, 2026
75a00ff
feat: make amp plugin installation optional
bornast Mar 31, 2026
ec0eddf
feat: make xray connection plugin installation optional
bornast Mar 31, 2026
5b5c231
feat: add dataSourceName prop
bornast Mar 31, 2026
46e116c
feat: add slo dashboard builder method
bornast Mar 31, 2026
aec0251
Merge branch 'feat/grafana-dashboards' into test/grafana
bornast Mar 31, 2026
a1505bb
fix: grafana test infrastructure
bornast Mar 31, 2026
f899b71
refactor: data source prop type
bornast Mar 31, 2026
a94815a
refactor: grafana dashboard name
bornast Mar 31, 2026
3a98fe0
refactor: rename slo dashboard
bornast Mar 31, 2026
9bf81b4
refactor: grafana builder error messages
bornast Mar 31, 2026
fe6f7b2
Merge branch 'feat/grafana-dashboards' into test/grafana
bornast Mar 31, 2026
a3e62c0
feat: introduce withTitle method inside grafana dashboard builder
bornast Apr 1, 2026
28c3140
Merge branch 'master' into test/grafana
bornast Apr 2, 2026
a5b62f5
test: amp grafana and configurable grafana
bornast Apr 2, 2026
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
187 changes: 187 additions & 0 deletions tests/grafana/amp-grafana.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { it } from 'node:test';
import * as assert from 'node:assert';
import * as studion from '@studion/infra-code-blocks';
import {
GetRoleCommand,
GetRolePolicyCommand,
ListRolePoliciesCommand,
} from '@aws-sdk/client-iam';
import { Unwrap } from '@pulumi/pulumi';
import { backOff } from '../util';
import { GrafanaTestContext } from './test-context';
import { grafanaRequest, requestEndpointWithExpectedStatus } from './util';

const backOffConfig = { numOfAttempts: 15 };

export function testAmpGrafana(ctx: GrafanaTestContext) {
it('should have created the AMP data source', async () => {
const grafana = ctx.outputs!.ampGrafana;
const ampDataSource = (
grafana.connections[0] as studion.grafana.AMPConnection
).dataSource;
const ampDataSourceName = ampDataSource.name as unknown as Unwrap<
typeof ampDataSource.name
>;

await backOff(async () => {
const { body, statusCode } = await grafanaRequest(
ctx,
'GET',
`/api/datasources/name/${encodeURIComponent(ampDataSourceName)}`,
);
assert.strictEqual(statusCode, 200, 'Expected data source to exist');

const data = (await body.json()) as Record<string, unknown>;
assert.strictEqual(
data.type,
'grafana-amazonprometheus-datasource',
'Expected Amazon Prometheus data source type',
);

const workspace = ctx.outputs!.ampWorkspace;
const ampEndpoint = workspace.prometheusEndpoint as unknown as Unwrap<
typeof workspace.prometheusEndpoint
>;
assert.ok(
(data.url as string).includes(ampEndpoint.replace(/\/$/, '')),
'Expected data source URL to contain the AMP workspace endpoint',
);
}, backOffConfig);
});

it('should have created the dashboard with expected panels', async () => {
const dashboard = ctx.outputs!.ampGrafana.dashboards[0];
const dashboardUid = dashboard.uid as unknown as Unwrap<
typeof dashboard.uid
>;

await backOff(async () => {
const { body, statusCode } = await grafanaRequest(
ctx,
'GET',
`/api/dashboards/uid/${dashboardUid}`,
);
assert.strictEqual(statusCode, 200, 'Expected dashboard to exist');

const data = (await body.json()) as {
dashboard: { title: string; panels: Array<{ title: string }> };
};
assert.strictEqual(
data.dashboard.title,
'ICB Grafana Test SLO',
'Expected dashboard title to match',
);

const panelTitles = data.dashboard.panels.map(p => p.title).sort();
const expectedPanels = [
'Availability',
'Availability Burn Rate',
'Success Rate',
'Success Rate Burn Rate',
'HTTP Request Success Rate',
'Request % below 250ms',
'Latency Burn Rate',
'99th Percentile Latency',
'Request percentage below 250ms',
];
assert.deepStrictEqual(
panelTitles,
expectedPanels.sort(),
'Dashboard panels do not match expected panels',
);
}, backOffConfig);
});

it('should display metrics data in the dashboard', async () => {
await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200);

const ampDataSource = (
ctx.outputs!.ampGrafana.connections[0] as studion.grafana.AMPConnection
).dataSource;
const ampDataSourceName = ampDataSource.name as unknown as Unwrap<
typeof ampDataSource.name
>;
const { body: dsBody } = await grafanaRequest(
ctx,
'GET',
`/api/datasources/name/${encodeURIComponent(ampDataSourceName)}`,
);
const dsData = (await dsBody.json()) as Record<string, unknown>;
const dataSourceUid = dsData.uid as string;

await backOff(async () => {
const { body, statusCode } = await grafanaRequest(
ctx,
'POST',
'/api/ds/query',
{
queries: [
{
datasource: {
type: 'grafana-amazonprometheus-datasource',
uid: dataSourceUid,
},
expr: `{__name__=~"${ctx.config.ampNamespace}_.*"}`,
instant: true,
refId: 'A',
},
],
from: 'now-5m',
to: 'now',
},
);
assert.strictEqual(statusCode, 200, 'Expected query to succeed');

const data = (await body.json()) as {
results: Record<string, { frames: Array<unknown> }>;
};
const frames = data.results?.A?.frames ?? [];
assert.ok(
frames.length > 0,
`Expected Grafana to return metric frames for namespace '${ctx.config.ampNamespace}'`,
);
}, backOffConfig);
});

it('should have created the IAM role with AMP inline policy', async () => {
const iamRole = ctx.outputs!.ampGrafana.connections[0].role;
const grafanaAmpRoleArn = iamRole.arn as unknown as Unwrap<
typeof iamRole.arn
>;
const roleName = grafanaAmpRoleArn.split('/').pop()!;
const { Role } = await ctx.clients.iam.send(
new GetRoleCommand({ RoleName: roleName }),
);
assert.ok(Role, 'Grafana IAM role should exist');

const { PolicyNames } = await ctx.clients.iam.send(
new ListRolePoliciesCommand({ RoleName: roleName }),
);
assert.ok(
PolicyNames && PolicyNames.length > 0,
'IAM role should have at least one inline policy',
);

const { PolicyDocument } = await ctx.clients.iam.send(
new GetRolePolicyCommand({
RoleName: roleName,
PolicyName: PolicyNames[0],
}),
);
const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as {
Statement: Array<{ Action: string[] }>;
};
const actions = policy.Statement.flatMap(s => s.Action).sort();
const expectedActions = [
'aps:GetSeries',
'aps:GetLabels',
'aps:GetMetricMetadata',
'aps:QueryMetrics',
].sort();
assert.deepStrictEqual(
actions,
expectedActions,
'AMP policy actions do not match expected actions',
);
});
}
66 changes: 66 additions & 0 deletions tests/grafana/configurable-grafana.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { it } from 'node:test';
import * as assert from 'node:assert';
import * as studion from '@studion/infra-code-blocks';
import {
GetRoleCommand,
GetRolePolicyCommand,
ListRolePoliciesCommand,
} from '@aws-sdk/client-iam';
import { Unwrap } from '@pulumi/pulumi';
import { backOff } from '../util';
import { GrafanaTestContext } from './test-context';
import { grafanaRequest } from './util';

const backOffConfig = { numOfAttempts: 15 };

export function testConfigurableGrafana(ctx: GrafanaTestContext) {
it('should have created the folder with the configured name', async () => {
const folder = ctx.outputs!.configurableGrafanaComponent.folder;
const folderUid = folder.uid as unknown as Unwrap<typeof folder.uid>;

await backOff(async () => {
const { body, statusCode } = await grafanaRequest(
ctx,
'GET',
`/api/folders/${folderUid}`,
);
assert.strictEqual(statusCode, 200, 'Expected folder to exist');

const data = (await body.json()) as { title: string };
assert.strictEqual(
data.title,
'ICB Configurable Test Folder',
'Expected folder title to match withFolderName() value',
);
}, backOffConfig);
});

it('should have created the custom dashboard', async () => {
const dashboard = ctx.outputs!.configurableGrafanaComponent.dashboards[0];
const dashboardUid = dashboard.uid as unknown as Unwrap<
typeof dashboard.uid
>;

await backOff(async () => {
const { body, statusCode } = await grafanaRequest(
ctx,
'GET',
`/api/dashboards/uid/${dashboardUid}`,
);
assert.strictEqual(statusCode, 200, 'Expected custom dashboard to exist');

const data = (await body.json()) as {
dashboard: { title: string; panels: Array<{ title: string }> };
};
assert.strictEqual(
data.dashboard.title,
'ICB Grafana Configurable Dashboard',
'Expected custom dashboard title',
);
assert.ok(
data.dashboard.panels.length > 0,
'Expected at least one panel',
);
}, backOffConfig);
});
}
42 changes: 42 additions & 0 deletions tests/grafana/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { before, describe, after } from 'node:test';
import { InlineProgramArgs, OutputMap } from '@pulumi/pulumi/automation';
import { IAMClient } from '@aws-sdk/client-iam';
import * as automation from '../automation';
import { requireEnv, unwrapOutputs } from '../util';
import { testAmpGrafana } from './amp-grafana.test';
import { testConfigurableGrafana } from './configurable-grafana.test';
import * as infraConfig from './infrastructure/config';
import { GrafanaTestContext, ProgramOutput } from './test-context';

const programArgs: InlineProgramArgs = {
stackName: 'dev',
projectName: 'icb-test-grafana',
program: () => import('./infrastructure'),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should other required env variables be explicitly required at this stage? Or can they be omitted?

requireEnv('GRAFANA_CLOUD_ACCESS_POLICY_TOKEN');
requireEnv('GRAFANA_AWS_ACCOUNT_ID');

const region = requireEnv('AWS_REGION');
const ctx: GrafanaTestContext = {
config: {
region,
usersPath: infraConfig.usersPath,
appName: infraConfig.appName,
ampNamespace: infraConfig.ampNamespace,
grafanaUrl: requireEnv('GRAFANA_URL'),
grafanaAuth: requireEnv('GRAFANA_AUTH'),
},
clients: {
iam: new IAMClient({ region }),
},
};

describe('Grafana component deployment', () => {
before(async () => {
const outputs: OutputMap = await automation.deploy(programArgs);
ctx.outputs = unwrapOutputs<ProgramOutput>(outputs);
});

after(() => automation.destroy(programArgs));

describe('AMP Grafana', () => testAmpGrafana(ctx));
describe('Configurable Grafana', () => testConfigurableGrafana(ctx));
});
11 changes: 11 additions & 0 deletions tests/grafana/infrastructure/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const appName = 'grafana-test';

export const appImage = 'studiondev/observability-sample-app';

export const appPort = 3000;

export const usersPath = '/users';

export const ampNamespace = 'icb_grafana_integration';

export const apiFilter = 'http_route=~"/.*"';
Loading