From 6eb274d57ef39d8dd7ed064e0b4aed4916c4e618 Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Fri, 3 Oct 2025 11:19:24 -0300 Subject: [PATCH 1/4] feat: add terraform HCL tab --- .../TerraformConfig/terraformTypes.ts | 1 + src/hooks/useTerraformConfig.ts | 23 +++- .../tabs/TerraformTab.test.tsx | 49 +++++++- .../ConfigPageLayout/tabs/TerraformTab.tsx | 116 +++++++++++++----- 4 files changed, 149 insertions(+), 40 deletions(-) diff --git a/src/components/TerraformConfig/terraformTypes.ts b/src/components/TerraformConfig/terraformTypes.ts index e617a06cb..58cc92561 100644 --- a/src/components/TerraformConfig/terraformTypes.ts +++ b/src/components/TerraformConfig/terraformTypes.ts @@ -18,6 +18,7 @@ import { export interface TFOutput { config: TFConfig; + hclConfig: string; checkCommands: string[]; checkAlertsCommands: string[]; probeCommands: string[]; diff --git a/src/hooks/useTerraformConfig.ts b/src/hooks/useTerraformConfig.ts index 5d4c8ad3d..f1e2a51d1 100644 --- a/src/hooks/useTerraformConfig.ts +++ b/src/hooks/useTerraformConfig.ts @@ -4,7 +4,14 @@ import { Check, Probe } from 'types'; import { useChecks } from 'data/useChecks'; import { useProbes } from 'data/useProbes'; import { checkToTF, probeToTF, sanitizeName } from 'components/TerraformConfig/terraformConfigUtils'; -import { TFCheckAlertsConfig,TFCheckConfig, TFConfig, TFOutput, TFProbeConfig } from 'components/TerraformConfig/terraformTypes'; +import { jsonToHcl } from 'components/TerraformConfig/terraformJsonToHcl'; +import { + TFCheckAlertsConfig, + TFCheckConfig, + TFConfig, + TFOutput, + TFProbeConfig, +} from 'components/TerraformConfig/terraformTypes'; import { useSMDS } from './useSMDS'; @@ -70,11 +77,11 @@ function generateTerraformConfig(probes: Probe[], checks: Check[], apiHost?: str const resourceName = sanitizeName(`${check.job}_${check.target}`); acc[resourceName] = { check_id: String(check.id), - alerts: (check.alerts!).map((alert) => ({ + alerts: check.alerts!.map((alert) => ({ name: alert.name, threshold: alert.threshold, period: alert.period, - runbook_url: alert.runbookUrl || "", + runbook_url: alert.runbookUrl || '', })), }; return acc; @@ -103,7 +110,15 @@ function generateTerraformConfig(probes: Probe[], checks: Check[], apiHost?: str return `terraform import grafana_synthetic_monitoring_probe.${probeName} ${probeId}:`; }); - return { config, checkCommands, checkAlertsCommands, probeCommands }; + const hclConfig = jsonToHcl(config); + + return { + config, + hclConfig, + checkCommands, + checkAlertsCommands, + probeCommands, + }; } export function useTerraformConfig() { diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx index 2c0a57e81..fa29b0fae 100644 --- a/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { DataTestIds } from '../../../test/dataTestIds'; import { BASIC_PING_CHECK } from '../../../test/fixtures/checks'; @@ -45,15 +46,36 @@ describe('TerraformTab', () => { expect(getByText('Synthetic Monitoring access token', { selector: 'a' })).toBeInTheDocument(); }); - it('should show "Terraform and JSON" ', async () => { - const { getByTestId } = await renderTerraformTab(); - // Terraform and JSON + it('should show HCL tab as default', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('HCL')).toBeInTheDocument(); + expect(getByText('JSON')).toBeInTheDocument(); + + // HCL should be active by default + const hclTab = getByText('HCL').closest('button'); + expect(hclTab).toHaveAttribute('aria-selected', 'true'); + }); + it('should show "Terraform HCL" alert by default', async () => { + const { getByTestId } = await renderTerraformTab(); const alert = getByTestId('data-testid Alert info'); - expect(within(alert).getByText('Terraform and JSON')).toBeInTheDocument(); + expect(within(alert).getByText('Terraform HCL')).toBeInTheDocument(); expect(alert).toBeInTheDocument(); }); + it('should switch to JSON tab and show "Terraform JSON" alert', async () => { + const user = userEvent.setup(); + const { getByText, getByTestId } = await renderTerraformTab(); + + // Click JSON tab + const jsonTab = getByText('JSON'); + await user.click(jsonTab); + + // Should show JSON alert + const alert = getByTestId('data-testid Alert info'); + expect(within(alert).getByText('Terraform JSON')).toBeInTheDocument(); + }); + it('should show `tf.json` with replace vars', async () => { const { getByText } = await renderTerraformTab(); expect(getByText('Exported config', { selector: 'h3' })).toBeInTheDocument(); @@ -61,8 +83,25 @@ describe('TerraformTab', () => { expect(getByText('SM_ACCESS_TOKEN', { selector: 'a > strong', exact: false })).toBeInTheDocument(); }); - it('should show correct terraform config', async () => { + it('should show correct terraform HCL config by default', async () => { const { getAllByTestId } = await renderTerraformTab(); + const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); + const content = preformatted[0].textContent ?? ''; + + // Should contain HCL syntax elements + expect(content).toContain('terraform {'); + expect(content).toContain('provider "grafana" {'); + expect(content).toContain('resource "grafana_synthetic_monitoring_check"'); + }); + + it('should show correct terraform JSON config when JSON tab is selected', async () => { + const user = userEvent.setup(); + const { getByText, getAllByTestId } = await renderTerraformTab(); + + // Click JSON tab + const jsonTab = getByText('JSON'); + await user.click(jsonTab); + const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); // Since content escapes '<' and '>', we need to replace them back const content = JSON.parse((preformatted[0].textContent ?? '').replace('<', '<').replace('>', '>')); diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx index 654ce91e1..965392cb8 100644 --- a/src/page/ConfigPageLayout/tabs/TerraformTab.tsx +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { Alert, Tab, TabContent,TabsBar, Text, TextLink, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { FaroEvent, reportEvent } from 'faro'; @@ -13,10 +13,14 @@ import { ContactAdminAlert } from 'page/ContactAdminAlert'; import { ConfigContent } from '../ConfigContent'; +type ConfigFormat = 'hcl' | 'json'; + export function TerraformTab() { - const { config, checkCommands, probeCommands, error, isLoading, checkAlertsCommands } = useTerraformConfig(); + const { config, hclConfig, checkCommands, probeCommands, error, isLoading, checkAlertsCommands } = useTerraformConfig(); const styles = useStyles2(getStyles); const { canReadChecks, canReadProbes } = getUserPermissions(); + const [activeFormat, setActiveFormat] = useState('hcl'); + useEffect(() => { reportEvent(FaroEvent.SHOW_TERRAFORM_CONFIG); }, []); @@ -60,34 +64,84 @@ export function TerraformTab() { - - The exported config is using{' '} - - Terraform JSON syntax - - . You can place this config in a file with a tf.json extension and import as a module. See the{' '} - - Terraform provider docs - {' '} - for more details. - - - Replace{' '} - - {''} - {' '} - and{' '} - - {''} - - , with their respective value. - - ', '']} - content={JSON.stringify(config, null, 2)} - className={styles.clipboard} - isCode - /> + + setActiveFormat('hcl')} + /> + setActiveFormat('json')} + /> + + + + {activeFormat === 'hcl' ? ( + <> + + The exported config is using{' '} + + Terraform HCL syntax + + . You can place this config in a file with a .tf extension and import as a module. See the{' '} + + Terraform provider docs + {' '} + for more details. + + + Replace{' '} + + {''} + {' '} + and{' '} + + {''} + + , with their respective value. + + ', '']} + content={hclConfig} + className={styles.clipboard} + isCode + /> + + ) : ( + <> + + The exported config is using{' '} + + Terraform JSON syntax + + . You can place this config in a file with a tf.json extension and import as a module. See the{' '} + + Terraform provider docs + {' '} + for more details. + + + Replace{' '} + + {''} + {' '} + and{' '} + + {''} + + , with their respective value. + + ', '']} + content={JSON.stringify(config, null, 2)} + className={styles.clipboard} + isCode + /> + + )} + {checkCommands && ( From 87aaeb41301866afc3ae91e880c2ae0ece2645d1 Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Fri, 3 Oct 2025 11:20:02 -0300 Subject: [PATCH 2/4] feat: add terraformJsonToHcl formatter --- .../TerraformConfig/hcl/core/HclWriter.ts | 77 ++++ .../TerraformConfig/hcl/core/hclConfig.ts | 23 + .../TerraformConfig/hcl/core/hclTypes.ts | 13 + .../TerraformConfig/hcl/core/hclUtils.ts | 70 +++ .../hcl/formatters/baseFormatter.ts | 34 ++ .../hcl/formatters/grpcFormatter.ts | 31 ++ .../hcl/formatters/multiHttpFormatter.ts | 154 +++++++ .../hcl/formatters/simpleFormatter.ts | 10 + .../TerraformConfig/hcl/terraformRenderer.ts | 173 +++++++ .../terraformJsonToHcl.test.ts | 425 ++++++++++++++++++ .../TerraformConfig/terraformJsonToHcl.ts | 49 ++ 11 files changed, 1059 insertions(+) create mode 100644 src/components/TerraformConfig/hcl/core/HclWriter.ts create mode 100644 src/components/TerraformConfig/hcl/core/hclConfig.ts create mode 100644 src/components/TerraformConfig/hcl/core/hclTypes.ts create mode 100644 src/components/TerraformConfig/hcl/core/hclUtils.ts create mode 100644 src/components/TerraformConfig/hcl/formatters/baseFormatter.ts create mode 100644 src/components/TerraformConfig/hcl/formatters/grpcFormatter.ts create mode 100644 src/components/TerraformConfig/hcl/formatters/multiHttpFormatter.ts create mode 100644 src/components/TerraformConfig/hcl/formatters/simpleFormatter.ts create mode 100644 src/components/TerraformConfig/hcl/terraformRenderer.ts create mode 100644 src/components/TerraformConfig/terraformJsonToHcl.test.ts create mode 100644 src/components/TerraformConfig/terraformJsonToHcl.ts diff --git a/src/components/TerraformConfig/hcl/core/HclWriter.ts b/src/components/TerraformConfig/hcl/core/HclWriter.ts new file mode 100644 index 000000000..27a242e8a --- /dev/null +++ b/src/components/TerraformConfig/hcl/core/HclWriter.ts @@ -0,0 +1,77 @@ +import { HCL_CONFIG } from './hclConfig'; +import { HclValue, HclWriterInterface } from './hclTypes'; +import { isHclArray, isHclObject } from './hclUtils'; + +export class HclWriter implements HclWriterInterface { + constructor(public readonly indentLevel = 0, public readonly indentSize = HCL_CONFIG.INDENT_SIZE) {} + + indent(): string { + return ' '.repeat(this.indentLevel * this.indentSize); + } + + child(): HclWriterInterface { + return new HclWriter(this.indentLevel + 1, this.indentSize); + } + + private escapeHclString(str: string): string { + return str + .replace(/\\/g, HCL_CONFIG.ESCAPE_CHARS['\\']) + .replace(/"/g, HCL_CONFIG.ESCAPE_CHARS['"']) + .replace(/\n/g, HCL_CONFIG.ESCAPE_CHARS['\n']) + .replace(/\r/g, HCL_CONFIG.ESCAPE_CHARS['\r']) + .replace(/\t/g, HCL_CONFIG.ESCAPE_CHARS['\t']) + .replace(/\$\{/g, HCL_CONFIG.ESCAPE_CHARS['${']); + } + + writeValue(value: HclValue): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'string') { + if (value.includes('\n')) { + const escapedValue = value.replace(/\$\{/g, HCL_CONFIG.ESCAPE_CHARS['${']); + return `< this.writeValue(item)); + return `[${items.join(', ')}]`; + } + + if (isHclObject(value)) { + const entries = Object.entries(value) + .filter(([_, v]) => v !== null && v !== undefined) + .map(([k, v]) => `${k} = ${this.writeValue(v)}`); + return `{ ${entries.join(', ')} }`; + } + + return String(value); + } + + writeBlock(name: string, content: string[]): string[] { + if (content.length === 0) { + return []; + } + + const lines: string[] = []; + lines.push(`${this.indent()}${name} {`); + lines.push(...content); + lines.push(`${this.indent()}}`); + return lines; + } + + writeArgument(key: string, value: HclValue): string { + return `${this.indent()}${key} = ${this.writeValue(value)}`; + } +} diff --git a/src/components/TerraformConfig/hcl/core/hclConfig.ts b/src/components/TerraformConfig/hcl/core/hclConfig.ts new file mode 100644 index 000000000..0a6b423b1 --- /dev/null +++ b/src/components/TerraformConfig/hcl/core/hclConfig.ts @@ -0,0 +1,23 @@ +export const HCL_CONFIG = { + BLOCK_FIELDS: new Set([ + 'tls_config', + 'basic_auth', + 'query_fields', + 'variables', + 'fail_if_header_matches_regexp', + 'fail_if_header_not_matches_regexp', + 'validate_answer_rrs', + 'validate_authority_rrs', + 'validate_additional_rrs', + 'query_response' + ]), + INDENT_SIZE: 2, + ESCAPE_CHARS: { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '${': '$$${', + }, +}; diff --git a/src/components/TerraformConfig/hcl/core/hclTypes.ts b/src/components/TerraformConfig/hcl/core/hclTypes.ts new file mode 100644 index 000000000..fc59c57a0 --- /dev/null +++ b/src/components/TerraformConfig/hcl/core/hclTypes.ts @@ -0,0 +1,13 @@ +export type HclValue = string | number | boolean | null | undefined | any[] | Record; + +export type FormatterFunction = (settings: Record, writer: HclWriterInterface) => string[]; + +export interface HclWriterInterface { + readonly indentLevel: number; + readonly indentSize: number; + indent(): string; + child(): HclWriterInterface; + writeValue(value: HclValue): string; + writeBlock(name: string, content: string[]): string[]; + writeArgument(key: string, value: HclValue): string; +} diff --git a/src/components/TerraformConfig/hcl/core/hclUtils.ts b/src/components/TerraformConfig/hcl/core/hclUtils.ts new file mode 100644 index 000000000..bfbebe24f --- /dev/null +++ b/src/components/TerraformConfig/hcl/core/hclUtils.ts @@ -0,0 +1,70 @@ +import { HCL_CONFIG } from './hclConfig'; +import { HclValue, HclWriterInterface } from './hclTypes'; + +export function isHclObject(value: HclValue): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isHclArray(value: HclValue): value is HclValue[] { + return Array.isArray(value); +} + +export function isEmptyArray(value: HclValue): boolean { + return isHclArray(value) && value.length === 0; +} + +export function isEmptyObject(value: HclValue): boolean { + return isHclObject(value) && Object.keys(value).length === 0; +} + +export function shouldRenderAsBlock(key: string, value: HclValue): boolean { + return HCL_CONFIG.BLOCK_FIELDS.has(key) && (isHclObject(value) || isHclArray(value)); +} + +export function renderFieldAsBlock(key: string, value: HclValue, writer: HclWriterInterface): string[] { + const lines: string[] = []; + + if (isHclArray(value)) { + value.forEach((item) => { + if (isHclObject(item)) { + const entries = Object.entries(item).filter(([_, v]) => v !== null && v !== undefined); + if (entries.length === 0) { + return; + } + + const blockContent: string[] = []; + const blockWriter = writer.child(); + + entries.forEach(([objKey, objValue]) => { + blockContent.push(blockWriter.writeArgument(objKey, objValue as HclValue)); + }); + + if (blockContent.length > 0) { + lines.push(...writer.writeBlock(key, blockContent)); + } + } + }); + return lines; + } + + if (isHclObject(value)) { + const entries = Object.entries(value).filter(([_, v]) => v !== null && v !== undefined); + + if (entries.length === 0) { + return lines; + } + + const blockContent: string[] = []; + const blockWriter = writer.child(); + + entries.forEach(([objKey, objValue]) => { + blockContent.push(blockWriter.writeArgument(objKey, objValue as HclValue)); + }); + + if (blockContent.length > 0) { + lines.push(...writer.writeBlock(key, blockContent)); + } + } + + return lines; +} diff --git a/src/components/TerraformConfig/hcl/formatters/baseFormatter.ts b/src/components/TerraformConfig/hcl/formatters/baseFormatter.ts new file mode 100644 index 000000000..7791690fc --- /dev/null +++ b/src/components/TerraformConfig/hcl/formatters/baseFormatter.ts @@ -0,0 +1,34 @@ +import { HclValue, HclWriterInterface } from '../core/hclTypes'; +import { isEmptyArray, isEmptyObject,renderFieldAsBlock, shouldRenderAsBlock } from '../core/hclUtils'; + +export function formatSettingsToHcl( + settings: Record, + writer: HclWriterInterface, + specialHandlers?: Record string[]> +): string[] { + const lines: string[] = []; + const childWriter = writer.child(); + + Object.entries(settings).forEach(([key, value]) => { + if (value === null || value === undefined) { + return; + } + + if (specialHandlers && specialHandlers[key]) { + lines.push(...specialHandlers[key](value, childWriter)); + return; + } + + if (shouldRenderAsBlock(key, value)) { + if (isEmptyArray(value) || isEmptyObject(value)) { + return; + } + lines.push(...renderFieldAsBlock(key, value, childWriter)); + return; + } + + lines.push(childWriter.writeArgument(key, value as HclValue)); + }); + + return lines; +} diff --git a/src/components/TerraformConfig/hcl/formatters/grpcFormatter.ts b/src/components/TerraformConfig/hcl/formatters/grpcFormatter.ts new file mode 100644 index 000000000..82b428bc4 --- /dev/null +++ b/src/components/TerraformConfig/hcl/formatters/grpcFormatter.ts @@ -0,0 +1,31 @@ +import { HclValue, HclWriterInterface } from '../core/hclTypes'; +import { isHclObject } from '../core/hclUtils'; +import { formatSettingsToHcl } from './baseFormatter'; + +export function formatGrpcSettingsToHcl( + grpcSettings: Record, + writer: HclWriterInterface +): string[] { + const specialHandlers = { + tls_config: (tlsConfig: HclValue, writer: HclWriterInterface) => { + if (isHclObject(tlsConfig)) { + const entries = Object.entries(tlsConfig).filter(([_, v]) => v !== null && v !== undefined); + if (entries.length === 0) { + return []; + } + + const tlsLines: string[] = []; + const tlsWriter = writer.child(); + + entries.forEach(([tlsKey, tlsValue]) => { + tlsLines.push(tlsWriter.writeArgument(tlsKey, tlsValue as HclValue)); + }); + + return writer.writeBlock('tls_config', tlsLines); + } + return []; + }, + }; + + return formatSettingsToHcl(grpcSettings, writer, specialHandlers); +} diff --git a/src/components/TerraformConfig/hcl/formatters/multiHttpFormatter.ts b/src/components/TerraformConfig/hcl/formatters/multiHttpFormatter.ts new file mode 100644 index 000000000..46ff2fc9e --- /dev/null +++ b/src/components/TerraformConfig/hcl/formatters/multiHttpFormatter.ts @@ -0,0 +1,154 @@ +import { Label } from 'types'; + +import { TFMultiHttpAssertion, TFMultiHttpEntry } from '../../terraformTypes'; +import { HclValue, HclWriterInterface } from '../core/hclTypes'; +import { isHclObject } from '../core/hclUtils'; +import { formatSettingsToHcl } from './baseFormatter'; + +export function renderMultiHttpAssertions(assertions: TFMultiHttpAssertion[], writer: HclWriterInterface): string[] { + const lines: string[] = []; + + assertions.forEach((assertion) => { + const assertionLines: string[] = []; + const assertionWriter = writer.child(); + + Object.entries(assertion).forEach(([assertionKey, assertionValue]) => { + if (assertionValue !== null && assertionValue !== undefined) { + assertionLines.push(assertionWriter.writeArgument(assertionKey, assertionValue as HclValue)); + } + }); + + if (assertionLines.length > 0) { + lines.push(...writer.writeBlock('assertions', assertionLines)); + } + }); + + return lines; +} + +export function renderMultiHttpEntry(entry: TFMultiHttpEntry, writer: HclWriterInterface): string[] { + const entryLines: string[] = []; + const entryWriter = writer.child(); + + const entryHandlers = { + request: (value: TFMultiHttpEntry['request']) => { + const requestLines = formatMultiHttpRequestToHcl(value!, entryWriter); + return requestLines.length > 0 ? entryWriter.writeBlock('request', requestLines) : []; + }, + assertions: (value: TFMultiHttpAssertion[]) => renderMultiHttpAssertions(value, entryWriter), + variables: (value: any[]) => { + const lines: string[] = []; + value.forEach((variable) => { + const variableLines: string[] = []; + const variableWriter = entryWriter.child(); + + Object.entries(variable).forEach(([varKey, varValue]) => { + if (varValue !== null && varValue !== undefined) { + variableLines.push(variableWriter.writeArgument(varKey, varValue as HclValue)); + } + }); + + if (variableLines.length > 0) { + lines.push(...entryWriter.writeBlock('variables', variableLines)); + } + }); + return lines; + }, + }; + + Object.entries(entry).forEach(([entryKey, entryValue]) => { + if (entryValue === null || entryValue === undefined) { + return; + } + + const handler = entryHandlers[entryKey as keyof typeof entryHandlers]; + if (handler) { + entryLines.push(...handler(entryValue)); + return; + } + + entryLines.push(entryWriter.writeArgument(entryKey, entryValue as HclValue)); + }); + + return entryLines; +} + +export function renderMultiHttpEntries(entries: TFMultiHttpEntry[], writer: HclWriterInterface): string[] { + const lines: string[] = []; + + entries.forEach((entry) => { + const entryLines = renderMultiHttpEntry(entry, writer); + + if (entryLines.length > 0) { + lines.push(...writer.writeBlock('entries', entryLines)); + } + }); + + return lines; +} + +export function formatMultiHttpRequestToHcl(request: TFMultiHttpEntry['request'], writer: HclWriterInterface): string[] { + const specialHandlers = { + headers: (headers: HclValue, writer: HclWriterInterface) => { + const lines: string[] = []; + (headers as Label[]).forEach((header) => { + const headerLines: string[] = []; + const headerWriter = writer.child(); + + headerLines.push(headerWriter.writeArgument('name', header.name)); + headerLines.push(headerWriter.writeArgument('value', header.value)); + + lines.push(...writer.writeBlock('headers', headerLines)); + }); + return lines; + }, + body: (body: HclValue, writer: HclWriterInterface) => { + if (isHclObject(body)) { + const bodyLines: string[] = []; + const bodyWriter = writer.child(); + + Object.entries(body).forEach(([bodyKey, bodyValue]) => { + if (bodyValue !== null && bodyValue !== undefined) { + bodyLines.push(bodyWriter.writeArgument(bodyKey, bodyValue as HclValue)); + } + }); + + return bodyLines.length > 0 ? writer.writeBlock('body', bodyLines) : []; + } + return [writer.writeArgument('body', body as HclValue)]; + }, + query_fields: (queryFields: HclValue, writer: HclWriterInterface) => { + const lines: string[] = []; + (queryFields as Label[]).forEach((field) => { + if (isHclObject(field)) { + const fieldLines: string[] = []; + const fieldWriter = writer.child(); + + Object.entries(field).forEach(([fieldKey, fieldValue]) => { + if (fieldValue !== null && fieldValue !== undefined) { + fieldLines.push(fieldWriter.writeArgument(fieldKey, fieldValue as HclValue)); + } + }); + + if (fieldLines.length > 0) { + lines.push(...writer.writeBlock('query_fields', fieldLines)); + } + } + }); + return lines; + }, + }; + + return formatSettingsToHcl(request as unknown as Record, writer, specialHandlers); +} + +export function formatMultiHttpSettingsToHcl( + multiHttpSettings: Record, + writer: HclWriterInterface +): string[] { + const specialHandlers = { + entries: (entries: HclValue) => renderMultiHttpEntries(entries as TFMultiHttpEntry[], writer), + }; + + return formatSettingsToHcl(multiHttpSettings, writer, specialHandlers); +} diff --git a/src/components/TerraformConfig/hcl/formatters/simpleFormatter.ts b/src/components/TerraformConfig/hcl/formatters/simpleFormatter.ts new file mode 100644 index 000000000..efc8a9156 --- /dev/null +++ b/src/components/TerraformConfig/hcl/formatters/simpleFormatter.ts @@ -0,0 +1,10 @@ +import { HclValue, HclWriterInterface } from '../core/hclTypes'; +import { formatSettingsToHcl } from './baseFormatter'; + +export function formatSimpleSettingsToHcl( + settings: Record, + writer: HclWriterInterface +): string[] { + // Simple formatters for http, dns, tcp, ping, traceroute, scripted, browser + return formatSettingsToHcl(settings, writer); +} diff --git a/src/components/TerraformConfig/hcl/terraformRenderer.ts b/src/components/TerraformConfig/hcl/terraformRenderer.ts new file mode 100644 index 000000000..808f1c3b1 --- /dev/null +++ b/src/components/TerraformConfig/hcl/terraformRenderer.ts @@ -0,0 +1,173 @@ +import { TFCheckAlerts, TFCheckSettings, TFConfig, TFLabels } from '../terraformTypes'; +import { HclValue, HclWriterInterface } from './core/hclTypes'; + +export function renderTerraformBlock(config: TFConfig, writer: HclWriterInterface): string[] { + const lines: string[] = []; + + if (config.terraform?.required_providers) { + const terraformLines: string[] = []; + const terraformWriter = writer.child(); + + const requiredProvidersLines: string[] = []; + const providersWriter = terraformWriter.child(); + + Object.entries(config.terraform.required_providers).forEach(([providerName, providerConfig]) => { + if (typeof providerConfig === 'object' && providerConfig !== null) { + const configEntries = Object.entries(providerConfig) + .filter(([_, value]) => value !== null && value !== undefined) + .map(([key, value]) => `${key} = ${writer.writeValue(value)}`); + + if (configEntries.length > 0) { + requiredProvidersLines.push(`${providersWriter.indent()}${providerName} = { ${configEntries.join(', ')} }`); + } + } + }); + + if (requiredProvidersLines.length > 0) { + terraformLines.push(...terraformWriter.writeBlock('required_providers', requiredProvidersLines)); + } + + if (terraformLines.length > 0) { + lines.push(...writer.writeBlock('terraform', terraformLines)); + } + } + + return lines; +} + +export function renderProviderBlocks(config: TFConfig, writer: HclWriterInterface): string[] { + if (!config.provider) { + return []; + } + + const lines: string[] = []; + + Object.entries(config.provider).forEach(([providerName, providerConfig]) => { + if (providerConfig && typeof providerConfig === 'object') { + const providerLines: string[] = []; + const providerWriter = writer.child(); + + Object.entries(providerConfig).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + providerLines.push(providerWriter.writeArgument(key, value as HclValue)); + } + }); + + if (providerLines.length > 0) { + lines.push(...writer.writeBlock(`provider "${providerName}"`, providerLines)); + } + } + }); + + return lines; +} + +export function renderResourceLabels(labels: TFLabels, writer: HclWriterInterface): string[] { + const entries = Object.entries(labels).filter(([_, value]) => value !== null && value !== undefined); + + if (entries.length === 0) { + return []; + } + + const labelsObject: Record = {}; + entries.forEach(([key, value]) => { + labelsObject[key] = value as HclValue; + }); + + return [writer.writeArgument('labels', labelsObject)]; +} + +export function renderResourceAlerts(alerts: TFCheckAlerts['alerts'], writer: HclWriterInterface): string[] { + const lines: string[] = []; + + alerts.forEach((alert) => { + const alertLines: string[] = []; + const alertWriter = writer.child(); + + Object.entries(alert).forEach(([alertKey, alertValue]) => { + if (alertValue !== null && alertValue !== undefined) { + alertLines.push(alertWriter.writeArgument(alertKey, alertValue as HclValue)); + } + }); + + if (alertLines.length > 0) { + lines.push(...writer.writeBlock('alerts', alertLines)); + } + }); + + return lines; +} + +export function renderSingleResource( + resourceType: string, + resourceName: string, + resourceConfig: Record, + writer: HclWriterInterface, + formatCheckSettings: (settingsType: string, settings: Record, writer: HclWriterInterface) => string[] +): string[] { + const resourceLines: string[] = []; + const resourceWriter = writer.child(); + + const fieldHandlers = { + settings: (value: TFCheckSettings) => { + const settingsLines: string[] = []; + const settingsWriter = resourceWriter.child(); + + Object.entries(value).forEach(([settingsType, settingsValue]) => { + if (!settingsValue || typeof settingsValue !== 'object') { + return; + } + const typeLines = formatCheckSettings(settingsType, settingsValue as Record, settingsWriter.child()); + if (typeLines.length > 0) { + settingsLines.push(...settingsWriter.writeBlock(settingsType, typeLines)); + } + }); + + return settingsLines.length > 0 ? resourceWriter.writeBlock('settings', settingsLines) : []; + }, + labels: (value: TFLabels) => renderResourceLabels(value, resourceWriter), + alerts: (value: TFCheckAlerts['alerts']) => renderResourceAlerts(value, resourceWriter), + }; + + Object.entries(resourceConfig).forEach(([key, value]) => { + if (value === null || value === undefined) { + return; + } + + const handler = fieldHandlers[key as keyof typeof fieldHandlers]; + if (handler) { + resourceLines.push(...handler(value)); + return; + } + + resourceLines.push(resourceWriter.writeArgument(key, value as HclValue)); + }); + + return resourceLines.length > 0 + ? writer.writeBlock(`resource "${resourceType}" "${resourceName}"`, resourceLines) + : []; +} + +export function renderResourceBlocks( + config: TFConfig, + writer: HclWriterInterface, + formatCheckSettings: (settingsType: string, settings: Record, writer: HclWriterInterface) => string[] +): string[] { + if (!config.resource) { + return []; + } + + const lines: string[] = []; + + Object.entries(config.resource).forEach(([resourceType, resources]) => { + if (resources && typeof resources === 'object') { + Object.entries(resources).forEach(([resourceName, resourceConfig]) => { + if (resourceConfig && typeof resourceConfig === 'object') { + lines.push(...renderSingleResource(resourceType, resourceName, resourceConfig, writer, formatCheckSettings)); + } + }); + } + }); + + return lines; +} diff --git a/src/components/TerraformConfig/terraformJsonToHcl.test.ts b/src/components/TerraformConfig/terraformJsonToHcl.test.ts new file mode 100644 index 000000000..7f80efeed --- /dev/null +++ b/src/components/TerraformConfig/terraformJsonToHcl.test.ts @@ -0,0 +1,425 @@ +import { HttpMethod } from '../../types'; + +import { jsonToHcl } from './terraformJsonToHcl'; +import { TFConfig } from './terraformTypes'; + +describe('terraformJsonToHcl', () => { + const baseConfig: Pick = { + terraform: { + required_providers: { + grafana: { + source: 'grafana/grafana', + }, + }, + }, + provider: { + grafana: { + url: 'http://localhost:3000', + auth: '', + sm_url: 'http://localhost:4000', + sm_access_token: '', + }, + }, + }; + + const createConfig = (resource: TFConfig['resource']): TFConfig => ({ + ...baseConfig, + resource, + }); + + const expectHclToContain = (hcl: string, expectations: string[]) => { + expectations.forEach(expectation => { + expect(hcl).toContain(expectation); + }); + }; + + const expectHclNotToContain = (hcl: string, expectations: string[]) => { + expectations.forEach(expectation => { + expect(hcl).not.toContain(expectation); + }); + }; + + describe('jsonToHcl', () => { + it('should convert basic TFConfig to HCL format', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + test_check: { + job: 'test-job', + target: 'https://example.com', + enabled: true, + probes: [1, 2], + labels: { + environment: 'test', + team: 'platform', + }, + settings: { + http: { + method: HttpMethod.GET, + ip_version: 'V4', + }, + }, + frequency: 60000, + timeout: 3000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + // Terraform block + 'terraform {', + 'required_providers {', + 'source = "grafana/grafana"', + // Provider block + 'provider "grafana" {', + 'url = "http://localhost:3000"', + 'auth = ""', + // Resource block + 'resource "grafana_synthetic_monitoring_check" "test_check" {', + 'job = "test-job"', + 'target = "https://example.com"', + 'enabled = true', + 'probes = [', + '1,', + '2', + // Labels + 'labels = {', + 'environment = "test"', + 'team = "platform"', + // Settings + 'settings {', + 'http {', + 'method = "GET"', + ]); + }); + + it('should handle multi-line scripts with heredoc syntax', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + browser_check: { + job: 'browser-test', + target: 'https://example.com', + enabled: true, + probes: [1], + labels: {}, + settings: { + browser: { + script: 'import { browser } from \'k6/browser\';\nimport { check } from \'k6\';\n\nexport default async function () {\n const page = await browser.newPage();\n await page.goto(`https://${BASE_URL}`);\n console.log(`Found ${productCards.length} items`);\n await page.close();\n}', + }, + }, + frequency: 60000, + timeout: 30000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'script = < { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + http_test: { + job: 'http-test', + target: 'https://grafana.com', + enabled: true, + probes: [12], + labels: {}, + settings: { + http: { + method: HttpMethod.GET, + headers: [ + 'Content-Type: application/json', + 'User-Agent: synthetic-monitoring' + ], + fail_if_not_ssl: false, + fail_if_ssl: false, + ip_version: 'V4', + no_follow_redirects: false + } + }, + frequency: 30000, + timeout: 14000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'resource "grafana_synthetic_monitoring_check" "http_test"', + 'http {', + 'method = "GET"', + // Headers as array in HTTP context + 'headers = [', + '"Content-Type: application/json"', + '"User-Agent: synthetic-monitoring"', + // Other expected fields + 'fail_if_not_ssl = false', + 'ip_version = "V4"', + ]); + }); + + it('should handle probe resources', () => { + const config = createConfig({ + grafana_synthetic_monitoring_probe: { + test_probe: { + name: 'test-probe', + latitude: 40.7128, + longitude: -74.0060, + region: 'us-east-1', + public: false, + labels: { + environment: 'test', + }, + disable_scripted_checks: false, + disable_browser_checks: false, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'resource "grafana_synthetic_monitoring_probe" "test_probe" {', + 'name = "test-probe"', + 'latitude = 40.7128', + 'longitude = -74.006', + 'region = "us-east-1"', + 'public = false', + 'disable_scripted_checks = false', + 'disable_browser_checks = false', + ]); + }); + + it('should handle check alerts', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check_alerts: { + test_alerts: { + check_id: '123', + alerts: [ + { + name: 'Test Alert', + threshold: 0.9, + period: '5m', + runbook_url: 'https://example.com/runbook', + }, + { + name: 'Another Alert', + threshold: 0.8, + period: '10m', + runbook_url: '', + } + ], + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'resource "grafana_synthetic_monitoring_check_alerts" "test_alerts" {', + 'check_id = "123"', + 'alerts {', + 'name = "Test Alert"', + 'threshold = 0.9', + 'period = "5m"', + 'runbook_url = "https://example.com/runbook"', + 'name = "Another Alert"', + 'threshold = 0.8', + ]); + }); + + it('should handle block fields correctly and skip empty objects', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + test_blocks: { + job: 'test-blocks', + target: 'https://example.com', + enabled: true, + probes: [1], + labels: {}, + settings: { + http: { + method: HttpMethod.GET, + ip_version: 'V4', + tls_config: {}, // Empty object - should not be rendered + basic_auth: { + username: 'user', + password: 'pass' + }, // Non-empty object - should be rendered as block + body: '{"test": true}' // Should be rendered as string argument + }, + }, + frequency: 60000, + timeout: 3000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'basic_auth {', + 'username = "user"', + 'password = "pass"', + 'body = "{\\"test\\": true}"', + ]); + + expectHclNotToContain(hcl, [ + 'tls_config', + ]); + }); + + it('should handle string escaping correctly', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + escape_test: { + job: 'escape-test', + target: 'https://example.com', + enabled: true, + probes: [1], + labels: { + 'special-chars': 'quotes"and\\backslashes\nand\ttabs\rand${template}', + }, + settings: { + http: { + method: HttpMethod.GET, + ip_version: 'V4', + body: 'JSON with "quotes" and \\ backslashes\nand\ttabs' + }, + }, + frequency: 60000, + timeout: 3000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'special-chars = < { + const config = createConfig({}); + + const hcl = jsonToHcl(config); + + expectHclToContain(hcl, [ + 'terraform {', + 'provider "grafana" {', + ]); + + expectHclNotToContain(hcl, [ + 'resource "grafana_synthetic_monitoring_check"', + ]); + }); + }); +}); diff --git a/src/components/TerraformConfig/terraformJsonToHcl.ts b/src/components/TerraformConfig/terraformJsonToHcl.ts new file mode 100644 index 000000000..f6fc0b159 --- /dev/null +++ b/src/components/TerraformConfig/terraformJsonToHcl.ts @@ -0,0 +1,49 @@ +import { FormatterFunction, HclValue, HclWriterInterface } from './hcl/core/hclTypes'; +import { HclWriter } from './hcl/core/HclWriter'; +import { formatGrpcSettingsToHcl } from './hcl/formatters/grpcFormatter'; +import { formatMultiHttpSettingsToHcl } from './hcl/formatters/multiHttpFormatter'; +import { formatSimpleSettingsToHcl } from './hcl/formatters/simpleFormatter'; +import { renderProviderBlocks, renderResourceBlocks,renderTerraformBlock } from './hcl/terraformRenderer'; +import { TFConfig } from './terraformTypes'; + +const SETTINGS_FORMATTERS: Record = { + multihttp: formatMultiHttpSettingsToHcl, + grpc: formatGrpcSettingsToHcl, + scripted: formatSimpleSettingsToHcl, + browser: formatSimpleSettingsToHcl, + // Default formatter for simple types (http, dns, tcp, ping, traceroute) + default: formatSimpleSettingsToHcl, +}; + +const formatCheckSettings = (settingsType: string, settings: Record, writer: HclWriterInterface): string[] => { + const formatter = SETTINGS_FORMATTERS[settingsType] || SETTINGS_FORMATTERS.default; + return formatter(settings, writer); +}; + +export function jsonToHcl(config: TFConfig): string { + const writer = new HclWriter(); + const lines: string[] = []; + + // Render terraform block + lines.push(...renderTerraformBlock(config, writer)); + + // Add spacing between blocks + if (lines.length > 0) { + lines.push(''); + } + + // Render provider blocks + const providerLines = renderProviderBlocks(config, writer); + if (providerLines.length > 0) { + lines.push(...providerLines); + lines.push(''); + } + + // Render resource blocks + const resourceLines = renderResourceBlocks(config, writer, formatCheckSettings); + if (resourceLines.length > 0) { + lines.push(...resourceLines); + } + + return lines.join('\n'); +} From 22d5a91c82fa31245d8204699bd9542b76c0b681 Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Fri, 3 Oct 2025 11:26:02 -0300 Subject: [PATCH 3/4] feat: include terraform hcl validation in CI check --- .github/workflows/call_validate-terraform.yml | 10 +++++-- .../generate-test-configs.ts | 27 +++++++++++++---- .../verify-terraform-test-config.sh | 30 +++++++++++++++---- .../tabs/TerraformTab.test.tsx | 2 +- src/test/fixtures/checks.ts | 17 +++++++++-- 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/.github/workflows/call_validate-terraform.yml b/.github/workflows/call_validate-terraform.yml index 627c8a32b..2f60120c8 100644 --- a/.github/workflows/call_validate-terraform.yml +++ b/.github/workflows/call_validate-terraform.yml @@ -27,11 +27,11 @@ jobs: yarn build:generate-terraform-test-config echo "โœ… Test configuration generation completed" - - name: Validate generated Terraform + - name: Validate generated Terraform (JSON and HCL) id: terraform-validate continue-on-error: true run: | - echo "๐Ÿ” Validating generated Terraform configuration..." + echo "๐Ÿ” Validating generated Terraform configuration (JSON and HCL)..." echo "๐Ÿ“ฆ Checking Grafana provider version..." # Extract provider version constraint from generated config @@ -185,6 +185,12 @@ jobs: let message = commentIdentifier + '\n\n'; message += 'All generated terraform configurations are valid and compatible with the Grafana provider schema. ๐ŸŽ‰\n\n'; + + message += '## โœ… Validation Results\n\n'; + message += '- **JSON Syntax**: Valid Terraform JSON configuration\n'; + message += '- **HCL Syntax**: Valid Terraform HCL configuration\n'; + message += '- **Schema Compatibility**: Compatible with Grafana provider\n\n'; + message += '**Validated Resources:**\n'; message += '- `grafana_synthetic_monitoring_check` (HTTP, DNS, TCP, Ping, MultiHTTP, Scripted, Traceroute)\n'; message += '- `grafana_synthetic_monitoring_probe` (Public, Private, Online, Offline)\n'; diff --git a/scripts/terraform-validation/generate-test-configs.ts b/scripts/terraform-validation/generate-test-configs.ts index 7130a165d..0b221d665 100644 --- a/scripts/terraform-validation/generate-test-configs.ts +++ b/scripts/terraform-validation/generate-test-configs.ts @@ -50,12 +50,18 @@ async function generateConfigs() { check: fixtures.BASIC_MULTIHTTP_CHECK, probe: probeFixtures.ONLINE_PROBE, }, - // Scripted Check - { - name: 'basic-scripted', - check: fixtures.BASIC_SCRIPTED_CHECK, - probe: probeFixtures.SCRIPTED_DISABLED_PROBE, - }, + // Scripted Check + { + name: 'basic-scripted', + check: fixtures.BASIC_SCRIPTED_CHECK, + probe: probeFixtures.SCRIPTED_DISABLED_PROBE, + }, + // Browser Check with template literals + { + name: 'complex-browser', + check: fixtures.COMPLEX_BROWSER_CHECK, + probe: probeFixtures.PRIVATE_PROBE, + }, // Traceroute Check { name: 'basic-traceroute', @@ -128,6 +134,15 @@ async function generateConfigs() { fs.writeFileSync(configPath, JSON.stringify(comprehensiveConfig, null, 2)); console.log(`Generated terraform config: ${configPath}`); + // Generate HCL configuration + console.log('๐Ÿ”ง Generating HCL configuration from JSON...'); + const { jsonToHcl } = await import('../../src/components/TerraformConfig/terraformJsonToHcl'); + const hclContent = jsonToHcl(comprehensiveConfig as TFConfig); + const hclPath = path.join(outputDir, 'testTerraformConfig.tf'); + fs.writeFileSync(hclPath, hclContent); + console.log(`โœ… HCL configuration generated successfully!`); + console.log(`๐Ÿ“„ Generated: ${hclPath}`); + console.log('\nโœ… SUCCESS! Generated comprehensive configuration using REAL production code!'); console.log('โœ… Covers: HTTP, DNS, TCP, Ping, MultiHTTP, Scripted, Traceroute checks'); console.log('โœ… Covers: Public, Private, Online, Offline, Scripted-disabled probes'); diff --git a/scripts/terraform-validation/verify-terraform-test-config.sh b/scripts/terraform-validation/verify-terraform-test-config.sh index 0d4e7cd6d..ce186320a 100755 --- a/scripts/terraform-validation/verify-terraform-test-config.sh +++ b/scripts/terraform-validation/verify-terraform-test-config.sh @@ -1,7 +1,7 @@ #!/bin/bash # Terraform validation script -# This script changes to the terraform validation directory and runs terraform validate +# This script validates both JSON and HCL formats set -e @@ -27,16 +27,36 @@ echo # Change to terraform directory and run validation cd "$TERRAFORM_DIR" -# Initialize terraform if not already done +# Prevent duplicate configurations (move HCL file) +echo "๐Ÿงช Prevent duplicate configurations..." +if [ -f "testTerraformConfig.tf" ]; then + echo " โ†’ Moving HCL file temporarily..." + mv testTerraformConfig.tf testTerraformConfig.tf.bak +fi + +# Initialize terraform if not already done (now only JSON exists) if [ ! -d ".terraform" ]; then echo "๐Ÿ“ฆ Initializing terraform..." terraform init echo fi +echo " โ†’ Running terraform validate for JSON..." +terraform validate +echo "โœ… JSON validation passed!" +echo " โ†’ Removing JSON file..." +rm testTerraformConfig.tf.json +echo + +# Restore HCL file and validate HCL format +echo "๐Ÿงช Restoring HCL configuration..." +if [ -f "testTerraformConfig.tf.bak" ]; then + echo " โ†’ Restoring HCL file..." + mv testTerraformConfig.tf.bak testTerraformConfig.tf +fi -# Run terraform validate -echo "๐Ÿงช Running terraform validate..." +echo " โ†’ Running terraform validate for HCL..." terraform validate +echo "โœ… HCL validation passed!" echo -echo "โœ… Terraform validation completed successfully!" \ No newline at end of file +echo "โœ… Both JSON and HCL terraform validation completed successfully!" \ No newline at end of file diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx index fa29b0fae..597f4d57f 100644 --- a/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx @@ -119,7 +119,7 @@ describe('TerraformTab', () => { expect(getByText('Import existing checks into Terraform', { selector: 'h3' })).toBeInTheDocument(); const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); expect(preformatted[1]).toHaveTextContent( - 'terraform import grafana_synthetic_monitoring_check.Job_name_for_ping_grafana_com 5' + 'terraform import grafana_synthetic_monitoring_check.Job_name_for_ping_grafana_com 6' ); }); diff --git a/src/test/fixtures/checks.ts b/src/test/fixtures/checks.ts index 8e41fceca..be3fbfd95 100644 --- a/src/test/fixtures/checks.ts +++ b/src/test/fixtures/checks.ts @@ -2,6 +2,7 @@ import { db } from 'test/db'; import { AlertSensitivity, + BrowserCheck, Check, CheckType, DNSCheck, @@ -107,6 +108,20 @@ export const BASIC_SCRIPTED_CHECK: ScriptedCheck = db.check.build( { transient: { type: CheckType.Scripted } } ) as ScriptedCheck; +export const COMPLEX_BROWSER_CHECK: BrowserCheck = db.check.build( + { + job: 'complex_browser_check', + target: 'complex_browser_check', + settings: { + browser: { + script: + "import { browser } from 'k6/browser';\nimport { check, group, sleep, fail } from 'k6';\n\nexport const options = {\n scenarios: {\n ui: {\n executor: 'shared-iterations',\n options: {\n browser: {\n type: 'chromium', \n },\n },\n },\n },\n thresholds: {\n checks: ['rate==1.0'],\n },\n};\n\nconst BASE_URL = 'https://otel-demo.field-eng.grafana.net/';\n\nexport default async function () {\n const context = await browser.newContext();\n const page = await context.newPage({\n viewport: {\n width: 1920,\n height: 1080,\n },\n });\n\n await browserNavigateToHomepage(page)\n\n await browserClickGoShopping(page);\n\n await browserSelectProduct(page);\n\n await browserAddToCart(page);\n\n await browserPlaceOrder(page);\n\n await page.close();\n\n}\n\n\nasync function browserNavigateToHomepage(page) {\n const navPromise = page.waitForNavigation();\n await page.goto(`${BASE_URL}`);1\n await navPromise;\n sleep(2);\n console.log(\"navigated to homepage\");\n}\n\nasync function browserClickGoShopping(page) {\n const navPromise = page.waitForNavigation();\n await page.locator('//button[text()=\"Go Shopping\"]').click();\n await navPromise;\n // additionally wait for a specific element (not async)\n page.locator('//h1[text()=\"Hot Products\"]').waitFor();\n sleep(2); \n console.log(\"clicked go shopping\");\n}\n\nasync function browserSelectProduct(page) {\n\n await page.waitForSelector('//div[@data-cy=\"product-list\"]');\n const productCards = await page.$$('//div[@data-cy=\"product-card\"]');\n console.log(`Found ${productCards.length} product cards`);\n\n //const randomProductCard = productCards[Math.floor(Math.random() * productCards.length)];\nconst randomProductCard = productCards[productCards.length - 1];\n const navPromise = page.waitForNavigation();\n await randomProductCard.click();\n await navPromise;\n\n sleep(2);\n\n console.log(\"selected product\");\n}\n\nasync function browserAddToCart(page) {\n\n const navPromise = page.waitForNavigation();\n await page.locator('//button[@data-cy=\"product-add-to-cart\"]').click();\n await navPromise;\n\n sleep(2);\n console.log(\"added to cart\");\n}\n\nasync function browserPlaceOrder(page) {\n const navPromise = page.waitForNavigation();\n await page.locator('//button[@data-cy=\"checkout-place-order\"]').click();\n await navPromise;\n\n // wait for confirmation header\n const response = await page.waitForSelector('//h1[text()=\"Your order is complete!\"]');\n const responseText = response.textContent();\n\n check(responseText, {\n responseText: (h) => h == \"Your order is complete!\",\n });\n sleep(2);\n}", + }, + }, + }, + { transient: { type: CheckType.Browser } } +) as BrowserCheck; + export const BASIC_MULTIHTTP_CHECK: MultiHTTPCheck = db.check.build( { job: 'Job name for multihttp', @@ -271,8 +286,6 @@ export const CUSTOM_ALERT_SENSITIVITY_CHECK: DNSCheck = db.check.build( { transient: { type: CheckType.DNS } } ) as DNSCheck; - - export const BASIC_CHECK_LIST: Check[] = [ BASIC_DNS_CHECK, BASIC_HTTP_CHECK, From 3a7871cd959425d30319c73bfc9cf5ee81cbe04b Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Mon, 13 Oct 2025 14:14:28 -0300 Subject: [PATCH 4/4] fix: review comments --- .../TerraformConfig/hcl/terraformRenderer.ts | 13 +++- .../terraformJsonToHcl.test.ts | 42 +++++++++++ .../tabs/TerraformConfigDisplay.tsx | 73 ++++++++++++++++++ .../ConfigPageLayout/tabs/TerraformTab.tsx | 75 ++++--------------- 4 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 src/page/ConfigPageLayout/tabs/TerraformConfigDisplay.tsx diff --git a/src/components/TerraformConfig/hcl/terraformRenderer.ts b/src/components/TerraformConfig/hcl/terraformRenderer.ts index 808f1c3b1..f0e111699 100644 --- a/src/components/TerraformConfig/hcl/terraformRenderer.ts +++ b/src/components/TerraformConfig/hcl/terraformRenderer.ts @@ -163,11 +163,22 @@ export function renderResourceBlocks( if (resources && typeof resources === 'object') { Object.entries(resources).forEach(([resourceName, resourceConfig]) => { if (resourceConfig && typeof resourceConfig === 'object') { - lines.push(...renderSingleResource(resourceType, resourceName, resourceConfig, writer, formatCheckSettings)); + const resourceLines = renderSingleResource(resourceType, resourceName, resourceConfig, writer, formatCheckSettings); + if (resourceLines.length > 0) { + // Add the resource lines + lines.push(...resourceLines); + // Add a newline after each resource for better readability + lines.push(''); + } } }); } }); + // Remove the trailing empty line if it exists + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines; } diff --git a/src/components/TerraformConfig/terraformJsonToHcl.test.ts b/src/components/TerraformConfig/terraformJsonToHcl.test.ts index 7f80efeed..a1f3a473c 100644 --- a/src/components/TerraformConfig/terraformJsonToHcl.test.ts +++ b/src/components/TerraformConfig/terraformJsonToHcl.test.ts @@ -421,5 +421,47 @@ describe('terraformJsonToHcl', () => { 'resource "grafana_synthetic_monitoring_check"', ]); }); + + it('should add newlines between resources for better readability', () => { + const config = createConfig({ + grafana_synthetic_monitoring_check: { + first_check: { + job: 'first-job', + target: 'https://first.com', + enabled: true, + probes: [1], + labels: {}, + settings: { + http: { + method: HttpMethod.GET, + ip_version: 'V4', + }, + }, + frequency: 60000, + timeout: 3000, + }, + second_check: { + job: 'second-job', + target: 'https://second.com', + enabled: true, + probes: [2], + labels: {}, + settings: { + http: { + method: HttpMethod.POST, + ip_version: 'V4', + }, + }, + frequency: 30000, + timeout: 5000, + }, + }, + }); + + const hcl = jsonToHcl(config); + + // Check that resources are separated by newlines - should have pattern: }\n\nresource + expect(hcl).toMatch(/}\s*\n\s*\nresource "grafana_synthetic_monitoring_check" "second_check"/); + }); }); }); diff --git a/src/page/ConfigPageLayout/tabs/TerraformConfigDisplay.tsx b/src/page/ConfigPageLayout/tabs/TerraformConfigDisplay.tsx new file mode 100644 index 000000000..1cbf137fb --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/TerraformConfigDisplay.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import { AppRoutes } from 'routing/types'; +import { generateRoutePath } from 'routing/utils'; +import { Clipboard } from 'components/Clipboard'; + +interface TerraformConfigDisplayProps { + title: string; + syntaxName: string; + docsUrl: string; + fileExtension: string; + content: string; +} + +export function TerraformConfigDisplay({ + title, + syntaxName, + docsUrl, + fileExtension, + content +}: TerraformConfigDisplayProps) { + const styles = useStyles2(getStyles); + + return ( + <> + + The exported config is using{' '} + + {syntaxName} + + . You can place this config in a file with a {fileExtension} extension and import as a module. See the{' '} + + Terraform provider docs + {' '} + for more details. + + + Replace{' '} + + {''} + {' '} + and{' '} + + {''} + + , with their respective value. + + ', '']} + content={content} + className={styles.clipboard} + isCode + /> + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + clipboard: css({ + maxHeight: 500, + marginTop: 10, + marginBottom: 10, + }), + codeLink: css({ + fontFamily: theme.typography.code.fontFamily, + fontSize: '0.8571428571em', + }), + }; +} diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx index 965392cb8..6e68d1ad8 100644 --- a/src/page/ConfigPageLayout/tabs/TerraformTab.tsx +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx @@ -12,6 +12,7 @@ import { Clipboard } from 'components/Clipboard'; import { ContactAdminAlert } from 'page/ContactAdminAlert'; import { ConfigContent } from '../ConfigContent'; +import { TerraformConfigDisplay } from './TerraformConfigDisplay'; type ConfigFormat = 'hcl' | 'json'; @@ -79,67 +80,21 @@ export function TerraformTab() { {activeFormat === 'hcl' ? ( - <> - - The exported config is using{' '} - - Terraform HCL syntax - - . You can place this config in a file with a .tf extension and import as a module. See the{' '} - - Terraform provider docs - {' '} - for more details. - - - Replace{' '} - - {''} - {' '} - and{' '} - - {''} - - , with their respective value. - - ', '']} - content={hclConfig} - className={styles.clipboard} - isCode - /> - + ) : ( - <> - - The exported config is using{' '} - - Terraform JSON syntax - - . You can place this config in a file with a tf.json extension and import as a module. See the{' '} - - Terraform provider docs - {' '} - for more details. - - - Replace{' '} - - {''} - {' '} - and{' '} - - {''} - - , with their respective value. - - ', '']} - content={JSON.stringify(config, null, 2)} - className={styles.clipboard} - isCode - /> - + )}