From 2a1d52c32642ddcb4d57a0766b38418ecf9575ca Mon Sep 17 00:00:00 2001 From: yduartep Date: Fri, 19 Sep 2025 09:31:51 +0200 Subject: [PATCH 01/12] ci: create new shared action to run e2e tests against pre-selected stack --- actions/plugins-e2e-tests/.gitignore | 17 ++ actions/plugins-e2e-tests/CHANGELOG.md | 5 + actions/plugins-e2e-tests/README.md | 57 +++++ actions/plugins-e2e-tests/action.yml | 171 ++++++++++++++ actions/plugins-e2e-tests/package.json | 30 +++ actions/plugins-e2e-tests/plopfile.mjs | 298 +++++++++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 actions/plugins-e2e-tests/.gitignore create mode 100644 actions/plugins-e2e-tests/CHANGELOG.md create mode 100644 actions/plugins-e2e-tests/README.md create mode 100644 actions/plugins-e2e-tests/action.yml create mode 100644 actions/plugins-e2e-tests/package.json create mode 100644 actions/plugins-e2e-tests/plopfile.mjs diff --git a/actions/plugins-e2e-tests/.gitignore b/actions/plugins-e2e-tests/.gitignore new file mode 100644 index 000000000..479ff5ee3 --- /dev/null +++ b/actions/plugins-e2e-tests/.gitignore @@ -0,0 +1,17 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +node_modules/ + +coverage/ +dist/ + +.*.bun-build + +# Editor +.idea diff --git a/actions/plugins-e2e-tests/CHANGELOG.md b/actions/plugins-e2e-tests/CHANGELOG.md new file mode 100644 index 000000000..0b63e2fd2 --- /dev/null +++ b/actions/plugins-e2e-tests/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +- Create new action plugins-e2e-tests to run tests aginst pre-selected stack. diff --git a/actions/plugins-e2e-tests/README.md b/actions/plugins-e2e-tests/README.md new file mode 100644 index 000000000..2420f378c --- /dev/null +++ b/actions/plugins-e2e-tests/README.md @@ -0,0 +1,57 @@ +# Run e2e tests from plugins against specific stack + +This is a [GitHub Action][github-action] that help the execution of e2e tests on any plugin against specific selected stacks. +This action use the following input parameters to run: + +| Name | Description | Default | Required | +|----------------------|-------------------------------------------------------------------------------------------------|--------------------|----------| +| `plugin_id` | Name of the plugin running the tests | | Yes | +| `stack_slug` | Name of the stack where you want to run the tests | | Yes | +| `env` | Region of the stack where you want to run the tests | | Yes | +| `other_plugins` | List of other plugins that you want to enable separated by comma | | No | +| `datasource_ids` | List of data sources that you want to enable separated by comma | | No | +| `upload_report_path `| Name of the folder where you want to store the test report | playwright-report | No | +| `upload_videos_path` | Name of the folder where you want to store the test videos | playwright-videos | No | +| `plugin-secrets` | A JSON string containing key-value pairs of specific plugin secrets necessary to run the tests. | | No | + +## Example workflows +This is an example of how you could use this action. + +```yml +name: Build and Test PR + +on: + pull_request: + +jobs: + e2e-tests: + permissions: + contents: write + id-token: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Get plugin specific secrets + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # v1.2.0 + with: + repo_secrets: | + MY_SECRET1=test:token1 + MY_SECRET2=test:token2 + + - name: Run e2e cross app tests + id: e2e-cross-apps-tests + uses: grafana/shared-workflows/actions/plugins-e2e-tests@main + with: + stack_slug: 'awsintegrationrevamp' + env: 'dev-central' + plugin_id: 'grafana-csp-app' + other_plugins: 'grafana-k8s-app,grafana-asserts-app' + datasource_ids: 'grafanacloud-awsintegrationrevamp-prom,grafanacloud-awsintegrationrevamp-logs' + upload_report_path: 'playwright-cross-apps-report' + upload_videos_path: 'playwright-cross-apps-videos' + plugin-secrets: ${{ ${{ steps.get-secrets.outputs.vault_secrets }} }} +``` diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml new file mode 100644 index 000000000..340c5e357 --- /dev/null +++ b/actions/plugins-e2e-tests/action.yml @@ -0,0 +1,171 @@ +name: Run e2e tests +description: Run e2e tests against specific stack and environment +inputs: + plugin_id: + description: 'Name of the plugin running the tests' + required: true + type: string + stack_slug: + description: 'Name of the stack where you want to run the tests' + required: true + type: string + env: + description: 'Region of the stack where you want to run the tests' + required: true + type: string + other_plugins: + description: 'List of other plugins that you want to enable separated by comma' + required: false + type: string + datasource_ids: + description: 'List of data sources that you want to enable separated by comma' + required: false + type: string + upload_report_path: + description: 'Name of the artifact where you want to store the test report' + required: false + type: string + default: 'playwright-report' + upload_videos_path: + description: 'Name of the artifact where you want to store the test videos' + required: false + type: string + default: 'playwright-videos' + plugin-secrets: + description: 'A JSON string containing key-value paris of specific plugin secrets necessary to run the tests.' + required: false + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Node.js environment + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + cache: 'yarn' + + - name: Install e2e action dependencies + run: yarn install + shell: bash + + - uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6 + id: gcloud-auth + working-directory: ${{ github.workspace }} + with: + token_format: access_token + workload_identity_provider: 'projects/304398677251/locations/global/workloadIdentityPools/github/providers/github-provider' + service_account: 'github-cloud-npm-dev-pkgs@grafanalabs-workload-identity.iam.gserviceaccount.com' + + - name: NPM registry auth + working-directory: ${{ github.workspace }} + run: npx google-artifactregistry-auth --credential-config + shell: bash + + - name: Install plugin dependencies + working-directory: ${{ github.workspace }} + run: yarn install --immutable --prefer-offline + shell: bash + + - name: Build frontend + working-directory: ${{ github.workspace }} + run: yarn run build + shell: bash + + - name: setup e2e env + id: get-secrets + working-directory: ${{ github.workspace }} + uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # v1.2.0 + with: + repo_secrets: | + HG_TOKEN=hg-ci:token + + - name: Set plugin secrets as environment variables + id: set-env-vars + if: ${{ inputs.plugin-secrets != '' }} + shell: bash + env: + SECRETS_JSON: '${{ inputs.plugin-secrets }}' + run: | + echo "Parsing and setting plugin environment variables..." + echo "$SECRETS_JSON" | jq -r 'to_entries[] | "echo \"\(.key)=\(.value)\" >> $GITHUB_ENV"' + echo "Plugin environment variables set." + + - name: Generate provisioning + shell: bash + run: npx plop e2e-testing-provisioning + working-directory: ${{ github.workspace }} + env: + E2E_STACK_SLUG: ${{ inputs.stack_slug }} + E2E_ENV: ${{ inputs.env }} + HG_TOKEN: ${{ env.HG_TOKEN }} + E2E_PLUGIN_ID: ${{ inputs.plugin_id }} + E2E_OTHER_PLUGINS: ${{ inputs.other_plugins }} + E2E_DATASOURCE_IDS: ${{ inputs.datasource_ids }} + + - name: Start server + working-directory: ${{ github.workspace }} + run: docker compose up -d --build --quiet-pull --timestamps + shell: bash + + - name: Install Playwright Browsers + working-directory: ${{ github.workspace }} + run: npx playwright install chromium --with-deps + shell: bash + + - name: Run Playwright tests + shell: bash + working-directory: ${{ github.workspace }} + env: + NODE_ENV: production + run: | + echo "Waiting for Grafana to be available..." + timeout=300 # 5 minutes timeout + start_time=$(date +%s) + + while ! docker logs grafana-csp-app 2>&1 | grep "Usage stats are ready to report" > /dev/null; do + current_time=$(date +%s) + elapsed=$((current_time - start_time)) + + if [ $elapsed -ge $timeout ]; then + echo "Timeout reached: Grafana did not become ready within 5 minutes." + exit 1 + fi + + echo "Waiting for Grafana..." + sleep 5 # Wait for 5 seconds before checking again + done + + echo "Grafana is ready!" + npx playwright test + + - name: Stop grafana docker + working-directory: ${{ github.workspace }} + run: docker compose down + shell: bash + + - name: Upload E2E report + working-directory: ${{ github.workspace }} + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ inputs.upload_report_path }} + path: playwright-report/ + retention-days: 30 + + - name: Upload E2E videos + working-directory: ${{ github.workspace }} + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ inputs.upload_videos_path }} + path: test-results/ + retention-days: 30 + +branding: + icon: "shield" + color: "green" diff --git a/actions/plugins-e2e-tests/package.json b/actions/plugins-e2e-tests/package.json new file mode 100644 index 000000000..be075bc4b --- /dev/null +++ b/actions/plugins-e2e-tests/package.json @@ -0,0 +1,30 @@ +{ + "name": "plugins-e2e-tests", + "version": "0.0.1", + "description": "Run e2e tests from plugins against specific stack and environment.", + "private": true, + "scripts": {}, + "keywords": [ + "e2e", + "github-action", + "playwright" + ], + "author": "", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.0", + "plop": "^4.0.1" + }, + "devDependencies": { + "@types/bun": "1.2.22", + "@eslint/js": "9.35.0", + "@types/eslint__js": "9.14.0", + "eslint": "9.35.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-jest": "29.0.1", + "eslint-plugin-prettier": "5.5.4", + "prettier": "3.6.2", + "typescript": "5.9.2", + "typescript-eslint": "8.44.0" + } +} diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs new file mode 100644 index 000000000..8ba90ac12 --- /dev/null +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -0,0 +1,298 @@ +import fs from "fs"; +import yaml from "js-yaml"; +import * as path from "path"; + +const HG_TOKEN = process.env.HG_TOKEN; +const APPS_YAML_FILE = path.join(process.cwd(), "./provisioning/plugins/apps.yaml"); +const DATASOURCES_YAML_FILE = path.join(process.cwd(), "./provisioning/datasources/default.yaml"); + +function formatDataSource(dataSource) { + if (dataSource) { + return { + name: dataSource.name, + type: dataSource.type, + url: dataSource.url, + basicAuth: dataSource.basicAuth === 1 || dataSource.basicAuth === true, + basicAuthUser: dataSource.basicAuthUser ? Number(dataSource.basicAuthUser) : undefined, + isDefault: dataSource.isDefault === 1 || dataSource.isDefault === true, + jsonData: dataSource.jsonData, + secureJsonData: { + basicAuthPassword: dataSource.basicAuthPassword, + }, + }; + } + return dataSource; +} + +function removeEmptyProperties(obj) { + // Check if the input is an object or an array + if (Array.isArray(obj)) { + // If it's an array, recursively clean each element + return obj + .map((item) => removeEmptyProperties(item)) + .filter((item) => item !== null && typeof item !== "undefined"); + } + + // Check if the input is a plain object + if (typeof obj === "object" && obj !== null) { + const newObj = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value = obj[key]; + + // Recursively clean nested objects/arrays + const cleanedValue = removeEmptyProperties(value); + + // Check for empty values and skip them + if ( + cleanedValue !== "" && + cleanedValue !== null && + cleanedValue !== undefined && + !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && + !(typeof cleanedValue === "object" && Object.keys(cleanedValue).length === 0) + ) { + newObj[key] = cleanedValue; + } + } + } + return newObj; + } + + // If the value is not an object or array, return it as is + return obj; +} + +function getBaseUrlByEnv(env) { + switch (env) { + case "prod-us-east": + return "https://hg-api-prod-us-east-0.grafana.net"; + case "prod": + return "https://hg-api-prod-us-central-0.grafana.net"; + case "ops": + return "https://hg-api-ops-eu-south-0.grafana-ops.net"; + case "dev-east": + return "https://hg-api-dev-us-east-0.grafana-dev.net"; + case "dev-central": + default: + return "https://hg-api-dev-us-central-0.grafana-dev.net"; + } +} + +async function fetchMultipleAppConfigs(stackSlug, env, pluginIds) { + try { + const fetchPromises = pluginIds.map((pluginId) => fetchAppConfig(stackSlug, env, pluginId)); + return await Promise.all(fetchPromises); + } catch (error) { + console.error("Error fetching multiple app configs:", error.message); + throw error; + } +} + +async function fetchAppConfig(stackSlug, env, pluginId) { + try { + const baseUrl = getBaseUrlByEnv(env); + const url = `${baseUrl}/instances/${stackSlug}/provisioned-plugins/${pluginId}`; + + const response = await fetch(url, { + headers: { + "User-Agent": `plop/${pluginId}-provisioning`, + Authorization: `Bearer ${HG_TOKEN}`, + }, + }); + return response.json(); + } catch (error) { + console.error("Error fetching app config", pluginId, ":", error.message); + throw error; + } +} + +async function fetchMultipleDatasources(stackSlug, env, datasourceNames) { + try { + const fetchPromises = datasourceNames.map((dsName) => fetchDataSource(stackSlug, env, dsName)); + if (fetchPromises.length > 0) { + return Promise.all(fetchPromises); + } + return Promise.all([]); + } catch (error) { + console.error("Error fetching multiple data sources:", error.message); + throw error; + } +} + +async function fetchDataSource(stackSlug, env, datasourceName) { + try { + const baseUrl = getBaseUrlByEnv(env); + const url = `${baseUrl}/instances/${stackSlug}/datasources/${datasourceName}`; + const response = await fetch(url, { + headers: { + "User-Agent": `plop/${datasourceName}-provisioning`, + Authorization: `Bearer ${HG_TOKEN}`, + }, + }); + const dataSourceWithToken = await response.json(); + const dataSourceWithNoEmptyField = removeEmptyProperties(dataSourceWithToken); + return formatDataSource(dataSourceWithNoEmptyField); + } catch (error) { + console.error("Error fetching datasource", datasourceName, ":", error.message); + throw error; + } +} + +function createDataSourcesYamlFile() { + const dir = path.dirname(DATASOURCES_YAML_FILE); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const initialContent = { + apiVersion: 1, + prune: true, + + datasources: [], + }; + fs.writeFileSync(DATASOURCES_YAML_FILE, yaml.dump(initialContent)); + return initialContent; +} + +async function fetchGrafanaConfig(stackSlug, env, pluginId) { + try { + const baseUrl = getBaseUrlByEnv(env); + const url = `${baseUrl}/instances/${stackSlug}/config`; + const response = await fetch(url, { + headers: { + "User-Agent": `plop/${pluginId}-provisioning`, + Authorization: `Bearer ${HG_TOKEN}`, + }, + }); + return response.json(); + } catch (error) { + console.error("Error fetching gcom token:", error.message); + throw error; + } +} + +function createAppsYamlFile() { + const dir = path.dirname(APPS_YAML_FILE); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const initialContent = { + apiVersion: 1, + + apps: [], + }; + fs.writeFileSync(APPS_YAML_FILE, yaml.dump(initialContent)); + return initialContent; +} + +function addAppConfigs(yamlData, appConfigs) { + appConfigs.forEach((appConfig) => { + if (appConfig.type === "grafana-asserts-app") { + appConfig.jsonData.instanceUrl = "http://localhost:3000"; + } + yamlData.apps.push(appConfig); + console.log(`App with type '${appConfig.type}' has been added`); + }); +} + +function addDataSourceConfigs(yamlData, dataSourceConfigs = []) { + dataSourceConfigs.forEach((dsConfig, i) => { + yamlData.datasources.push(dsConfig); + console.log(`Data source with type '${dsConfig.type}' and name '${dsConfig.name}' has been added`); + }); +} + +function writeDataSourcesYamlFile(yamlData) { + const yamlString = yaml.dump(yamlData); + fs.writeFileSync(DATASOURCES_YAML_FILE, yamlString); + console.log("default.yaml data source file has been updated."); +} + +function writeAppsYamlFile(yamlData) { + const yamlString = yaml.dump(yamlData); + + // just for asserts + const fixed = yamlString.replace("enableGrafanaManagedLLM: true", "enableGrafanaManagedLLM: false"); + fs.writeFileSync(APPS_YAML_FILE, fixed); + console.log("apps.yaml plugins file has been updated. Asserts prop enableGrafanaManagedLLM was disabled"); +} + +async function fillAnswers(answers) { + const appConfigs = await fetchMultipleAppConfigs(answers.STACK_SLUG, answers.ENV, answers.PLUGIN_IDS); + const yamlAppsData = createAppsYamlFile(); + addAppConfigs(yamlAppsData, appConfigs); + writeAppsYamlFile(yamlAppsData); + + const dataSourceConfigs = await fetchMultipleDatasources(answers.STACK_SLUG, answers.ENV, answers.DATASOURCE_IDS); + const yamlDataSourcesData = createDataSourcesYamlFile(); + addDataSourceConfigs(yamlDataSourcesData, dataSourceConfigs); + writeDataSourcesYamlFile(yamlDataSourcesData); + + const grafanaConfig = await fetchGrafanaConfig(answers.STACK_SLUG, answers.ENV, answers.GF_PLUGIN_ID); + answers.GF_GRAFANA_COM_SSO_API_TOKEN = grafanaConfig.hosted_grafana.hg_auth_token; + + // Use hardcoded URL for ops stack when grafana_net.url is missing + const grafanaNetUrl = + answers.STACK_SLUG === "ops" && !grafanaConfig.grafana_net?.url + ? "https://grafana-ops.com" + : grafanaConfig.grafana_net.url; + + answers.GF_GRAFANA_COM_URL = grafanaNetUrl; + answers.GF_GRAFANA_COM_API_URL = `${grafanaNetUrl}/api`; + answers.GF_PLUGINS_PREINSTALL_SYNC = answers.PLUGIN_IDS.filter((p) => p !== answers.GF_PLUGIN_ID).join(","); +} + +export default function(plop) { + plop.setHelper("env", (text) => process.env[text]); + + plop.setGenerator("e2e-testing-provisioning", { + prompts: [], + actions: [ + async function loadRemoteProvisioning(answers) { + try { + if (!HG_TOKEN) { + console.error("HG_TOKEN environment variable is not set."); + process.exit(1); + } + + if (!process.env.E2E_STACK_SLUG) { + console.error("E2E_STACK_SLUG environment variable is not set."); + process.exit(1); + } + + if (!process.env.E2E_PLUGIN_ID) { + console.error("E2E_PLUGIN_ID environment variable is not set."); + process.exit(1); + } + answers.STACK_SLUG = process.env.E2E_STACK_SLUG; + answers.ENV = process.env.E2E_ENV; + + answers.GF_PLUGIN_ID = process.env.E2E_PLUGIN_ID; + const otherPlugins = process.env.E2E_OTHER_PLUGINS ? process.env.E2E_OTHER_PLUGINS.split(",").map((i) => i.trim()) : []; + answers.PLUGIN_IDS = [process.env.E2E_PLUGIN_ID].concat(otherPlugins); + + answers.DATASOURCE_IDS = process.env.E2E_DATASOURCE_IDS + ? process.env.E2E_DATASOURCE_IDS.split(",").map((i) => i.trim()) + : []; + answers.GF_PLUGINS_PREINSTALL_SYNC = otherPlugins.join(","); + + await fillAnswers(answers); + + return "Remote Provisioning data loaded successfully for e2e tests."; + } catch (error) { + console.error("Failed to load Remote Provisioning:", error.message); + return "Failed to load Remote Provisioning data"; + } + }, + { + type: "add", + path: "./docker-compose.yaml", + templateFile: "plop-templates/docker-compose.hbs.yaml", + force: true, + }, + ], + }); +} From 26b2f2145472b207aef1ab213d93f18e2785a3f1 Mon Sep 17 00:00:00 2001 From: yduartep Date: Fri, 19 Sep 2025 09:37:36 +0200 Subject: [PATCH 02/12] ci: update readme --- actions/plugins-e2e-tests/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/actions/plugins-e2e-tests/README.md b/actions/plugins-e2e-tests/README.md index 2420f378c..4a5a68c72 100644 --- a/actions/plugins-e2e-tests/README.md +++ b/actions/plugins-e2e-tests/README.md @@ -1,6 +1,8 @@ # Run e2e tests from plugins against specific stack This is a [GitHub Action][github-action] that help the execution of e2e tests on any plugin against specific selected stacks. +You need to define in which region the selected stack belong, the plugin from where are executed the tests and optionally which other plugins and datasources you want to provision when starting a Grafana instance. +Also, you need to have the **playwright** configuration and the test specifications in the plugin that run the tests and the action will do the rest. This action use the following input parameters to run: | Name | Description | Default | Required | From 6033d4fee4aca9efb4029c7d015b4189de744e67 Mon Sep 17 00:00:00 2001 From: yduartep Date: Fri, 19 Sep 2025 09:40:40 +0200 Subject: [PATCH 03/12] ci: remove type --- actions/plugins-e2e-tests/action.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index 340c5e357..a984f846b 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -4,33 +4,33 @@ inputs: plugin_id: description: 'Name of the plugin running the tests' required: true - type: string + stack_slug: description: 'Name of the stack where you want to run the tests' required: true - type: string + env: description: 'Region of the stack where you want to run the tests' required: true - type: string + other_plugins: description: 'List of other plugins that you want to enable separated by comma' required: false - type: string + datasource_ids: description: 'List of data sources that you want to enable separated by comma' required: false - type: string + upload_report_path: description: 'Name of the artifact where you want to store the test report' required: false - type: string default: 'playwright-report' + upload_videos_path: description: 'Name of the artifact where you want to store the test videos' required: false - type: string default: 'playwright-videos' + plugin-secrets: description: 'A JSON string containing key-value paris of specific plugin secrets necessary to run the tests.' required: false From aa44284c379753b73cdd16133a704c42dc984810 Mon Sep 17 00:00:00 2001 From: yduartep Date: Fri, 19 Sep 2025 10:43:39 +0200 Subject: [PATCH 04/12] ci: fix lint format --- actions/plugins-e2e-tests/README.md | 35 +++++------ actions/plugins-e2e-tests/action.yml | 32 +++++----- actions/plugins-e2e-tests/plopfile.mjs | 81 ++++++++++++++++++++------ 3 files changed, 97 insertions(+), 51 deletions(-) diff --git a/actions/plugins-e2e-tests/README.md b/actions/plugins-e2e-tests/README.md index 4a5a68c72..e5e74c936 100644 --- a/actions/plugins-e2e-tests/README.md +++ b/actions/plugins-e2e-tests/README.md @@ -5,18 +5,19 @@ You need to define in which region the selected stack belong, the plugin from wh Also, you need to have the **playwright** configuration and the test specifications in the plugin that run the tests and the action will do the rest. This action use the following input parameters to run: -| Name | Description | Default | Required | -|----------------------|-------------------------------------------------------------------------------------------------|--------------------|----------| -| `plugin_id` | Name of the plugin running the tests | | Yes | -| `stack_slug` | Name of the stack where you want to run the tests | | Yes | -| `env` | Region of the stack where you want to run the tests | | Yes | -| `other_plugins` | List of other plugins that you want to enable separated by comma | | No | -| `datasource_ids` | List of data sources that you want to enable separated by comma | | No | -| `upload_report_path `| Name of the folder where you want to store the test report | playwright-report | No | -| `upload_videos_path` | Name of the folder where you want to store the test videos | playwright-videos | No | -| `plugin-secrets` | A JSON string containing key-value pairs of specific plugin secrets necessary to run the tests. | | No | +| Name | Description | Default | Required | +| --------------------- | ----------------------------------------------------------------------------------------------- | ----------------- | -------- | +| `plugin_id` | Name of the plugin running the tests | | Yes | +| `stack_slug` | Name of the stack where you want to run the tests | | Yes | +| `env` | Region of the stack where you want to run the tests | | Yes | +| `other_plugins` | List of other plugins that you want to enable separated by comma | | No | +| `datasource_ids` | List of data sources that you want to enable separated by comma | | No | +| `upload_report_path ` | Name of the folder where you want to store the test report | playwright-report | No | +| `upload_videos_path` | Name of the folder where you want to store the test videos | playwright-videos | No | +| `plugin-secrets` | A JSON string containing key-value pairs of specific plugin secrets necessary to run the tests. | | No | ## Example workflows + This is an example of how you could use this action. ```yml @@ -48,12 +49,12 @@ jobs: id: e2e-cross-apps-tests uses: grafana/shared-workflows/actions/plugins-e2e-tests@main with: - stack_slug: 'awsintegrationrevamp' - env: 'dev-central' - plugin_id: 'grafana-csp-app' - other_plugins: 'grafana-k8s-app,grafana-asserts-app' - datasource_ids: 'grafanacloud-awsintegrationrevamp-prom,grafanacloud-awsintegrationrevamp-logs' - upload_report_path: 'playwright-cross-apps-report' - upload_videos_path: 'playwright-cross-apps-videos' + stack_slug: "awsintegrationrevamp" + env: "dev-central" + plugin_id: "grafana-csp-app" + other_plugins: "grafana-k8s-app,grafana-asserts-app" + datasource_ids: "grafanacloud-awsintegrationrevamp-prom,grafanacloud-awsintegrationrevamp-logs" + upload_report_path: "playwright-cross-apps-report" + upload_videos_path: "playwright-cross-apps-videos" plugin-secrets: ${{ ${{ steps.get-secrets.outputs.vault_secrets }} }} ``` diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index a984f846b..7866add77 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -2,41 +2,41 @@ name: Run e2e tests description: Run e2e tests against specific stack and environment inputs: plugin_id: - description: 'Name of the plugin running the tests' + description: "Name of the plugin running the tests" required: true stack_slug: - description: 'Name of the stack where you want to run the tests' + description: "Name of the stack where you want to run the tests" required: true env: - description: 'Region of the stack where you want to run the tests' + description: "Region of the stack where you want to run the tests" required: true other_plugins: - description: 'List of other plugins that you want to enable separated by comma' + description: "List of other plugins that you want to enable separated by comma" required: false datasource_ids: - description: 'List of data sources that you want to enable separated by comma' + description: "List of data sources that you want to enable separated by comma" required: false upload_report_path: - description: 'Name of the artifact where you want to store the test report' + description: "Name of the artifact where you want to store the test report" required: false - default: 'playwright-report' + default: "playwright-report" upload_videos_path: - description: 'Name of the artifact where you want to store the test videos' + description: "Name of the artifact where you want to store the test videos" required: false - default: 'playwright-videos' + default: "playwright-videos" plugin-secrets: - description: 'A JSON string containing key-value paris of specific plugin secrets necessary to run the tests.' + description: "A JSON string containing key-value paris of specific plugin secrets necessary to run the tests." required: false runs: - using: 'composite' + using: "composite" steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -46,8 +46,8 @@ runs: - name: Setup Node.js environment uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' - cache: 'yarn' + node-version: "20" + cache: "yarn" - name: Install e2e action dependencies run: yarn install @@ -58,8 +58,8 @@ runs: working-directory: ${{ github.workspace }} with: token_format: access_token - workload_identity_provider: 'projects/304398677251/locations/global/workloadIdentityPools/github/providers/github-provider' - service_account: 'github-cloud-npm-dev-pkgs@grafanalabs-workload-identity.iam.gserviceaccount.com' + workload_identity_provider: "projects/304398677251/locations/global/workloadIdentityPools/github/providers/github-provider" + service_account: "github-cloud-npm-dev-pkgs@grafanalabs-workload-identity.iam.gserviceaccount.com" - name: NPM registry auth working-directory: ${{ github.workspace }} @@ -89,7 +89,7 @@ runs: if: ${{ inputs.plugin-secrets != '' }} shell: bash env: - SECRETS_JSON: '${{ inputs.plugin-secrets }}' + SECRETS_JSON: "${{ inputs.plugin-secrets }}" run: | echo "Parsing and setting plugin environment variables..." echo "$SECRETS_JSON" | jq -r 'to_entries[] | "echo \"\(.key)=\(.value)\" >> $GITHUB_ENV"' diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs index 8ba90ac12..ce0e061db 100644 --- a/actions/plugins-e2e-tests/plopfile.mjs +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -3,8 +3,14 @@ import yaml from "js-yaml"; import * as path from "path"; const HG_TOKEN = process.env.HG_TOKEN; -const APPS_YAML_FILE = path.join(process.cwd(), "./provisioning/plugins/apps.yaml"); -const DATASOURCES_YAML_FILE = path.join(process.cwd(), "./provisioning/datasources/default.yaml"); +const APPS_YAML_FILE = path.join( + process.cwd(), + "./provisioning/plugins/apps.yaml", +); +const DATASOURCES_YAML_FILE = path.join( + process.cwd(), + "./provisioning/datasources/default.yaml", +); function formatDataSource(dataSource) { if (dataSource) { @@ -13,7 +19,9 @@ function formatDataSource(dataSource) { type: dataSource.type, url: dataSource.url, basicAuth: dataSource.basicAuth === 1 || dataSource.basicAuth === true, - basicAuthUser: dataSource.basicAuthUser ? Number(dataSource.basicAuthUser) : undefined, + basicAuthUser: dataSource.basicAuthUser + ? Number(dataSource.basicAuthUser) + : undefined, isDefault: dataSource.isDefault === 1 || dataSource.isDefault === true, jsonData: dataSource.jsonData, secureJsonData: { @@ -49,7 +57,10 @@ function removeEmptyProperties(obj) { cleanedValue !== null && cleanedValue !== undefined && !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && - !(typeof cleanedValue === "object" && Object.keys(cleanedValue).length === 0) + !( + typeof cleanedValue === "object" && + Object.keys(cleanedValue).length === 0 + ) ) { newObj[key] = cleanedValue; } @@ -80,7 +91,9 @@ function getBaseUrlByEnv(env) { async function fetchMultipleAppConfigs(stackSlug, env, pluginIds) { try { - const fetchPromises = pluginIds.map((pluginId) => fetchAppConfig(stackSlug, env, pluginId)); + const fetchPromises = pluginIds.map((pluginId) => + fetchAppConfig(stackSlug, env, pluginId), + ); return await Promise.all(fetchPromises); } catch (error) { console.error("Error fetching multiple app configs:", error.message); @@ -108,7 +121,9 @@ async function fetchAppConfig(stackSlug, env, pluginId) { async function fetchMultipleDatasources(stackSlug, env, datasourceNames) { try { - const fetchPromises = datasourceNames.map((dsName) => fetchDataSource(stackSlug, env, dsName)); + const fetchPromises = datasourceNames.map((dsName) => + fetchDataSource(stackSlug, env, dsName), + ); if (fetchPromises.length > 0) { return Promise.all(fetchPromises); } @@ -130,10 +145,16 @@ async function fetchDataSource(stackSlug, env, datasourceName) { }, }); const dataSourceWithToken = await response.json(); - const dataSourceWithNoEmptyField = removeEmptyProperties(dataSourceWithToken); + const dataSourceWithNoEmptyField = + removeEmptyProperties(dataSourceWithToken); return formatDataSource(dataSourceWithNoEmptyField); } catch (error) { - console.error("Error fetching datasource", datasourceName, ":", error.message); + console.error( + "Error fetching datasource", + datasourceName, + ":", + error.message, + ); throw error; } } @@ -201,7 +222,9 @@ function addAppConfigs(yamlData, appConfigs) { function addDataSourceConfigs(yamlData, dataSourceConfigs = []) { dataSourceConfigs.forEach((dsConfig, i) => { yamlData.datasources.push(dsConfig); - console.log(`Data source with type '${dsConfig.type}' and name '${dsConfig.name}' has been added`); + console.log( + `Data source with type '${dsConfig.type}' and name '${dsConfig.name}' has been added`, + ); }); } @@ -215,24 +238,42 @@ function writeAppsYamlFile(yamlData) { const yamlString = yaml.dump(yamlData); // just for asserts - const fixed = yamlString.replace("enableGrafanaManagedLLM: true", "enableGrafanaManagedLLM: false"); + const fixed = yamlString.replace( + "enableGrafanaManagedLLM: true", + "enableGrafanaManagedLLM: false", + ); fs.writeFileSync(APPS_YAML_FILE, fixed); - console.log("apps.yaml plugins file has been updated. Asserts prop enableGrafanaManagedLLM was disabled"); + console.log( + "apps.yaml plugins file has been updated. Asserts prop enableGrafanaManagedLLM was disabled", + ); } async function fillAnswers(answers) { - const appConfigs = await fetchMultipleAppConfigs(answers.STACK_SLUG, answers.ENV, answers.PLUGIN_IDS); + const appConfigs = await fetchMultipleAppConfigs( + answers.STACK_SLUG, + answers.ENV, + answers.PLUGIN_IDS, + ); const yamlAppsData = createAppsYamlFile(); addAppConfigs(yamlAppsData, appConfigs); writeAppsYamlFile(yamlAppsData); - const dataSourceConfigs = await fetchMultipleDatasources(answers.STACK_SLUG, answers.ENV, answers.DATASOURCE_IDS); + const dataSourceConfigs = await fetchMultipleDatasources( + answers.STACK_SLUG, + answers.ENV, + answers.DATASOURCE_IDS, + ); const yamlDataSourcesData = createDataSourcesYamlFile(); addDataSourceConfigs(yamlDataSourcesData, dataSourceConfigs); writeDataSourcesYamlFile(yamlDataSourcesData); - const grafanaConfig = await fetchGrafanaConfig(answers.STACK_SLUG, answers.ENV, answers.GF_PLUGIN_ID); - answers.GF_GRAFANA_COM_SSO_API_TOKEN = grafanaConfig.hosted_grafana.hg_auth_token; + const grafanaConfig = await fetchGrafanaConfig( + answers.STACK_SLUG, + answers.ENV, + answers.GF_PLUGIN_ID, + ); + answers.GF_GRAFANA_COM_SSO_API_TOKEN = + grafanaConfig.hosted_grafana.hg_auth_token; // Use hardcoded URL for ops stack when grafana_net.url is missing const grafanaNetUrl = @@ -242,10 +283,12 @@ async function fillAnswers(answers) { answers.GF_GRAFANA_COM_URL = grafanaNetUrl; answers.GF_GRAFANA_COM_API_URL = `${grafanaNetUrl}/api`; - answers.GF_PLUGINS_PREINSTALL_SYNC = answers.PLUGIN_IDS.filter((p) => p !== answers.GF_PLUGIN_ID).join(","); + answers.GF_PLUGINS_PREINSTALL_SYNC = answers.PLUGIN_IDS.filter( + (p) => p !== answers.GF_PLUGIN_ID, + ).join(","); } -export default function(plop) { +export default function (plop) { plop.setHelper("env", (text) => process.env[text]); plop.setGenerator("e2e-testing-provisioning", { @@ -271,7 +314,9 @@ export default function(plop) { answers.ENV = process.env.E2E_ENV; answers.GF_PLUGIN_ID = process.env.E2E_PLUGIN_ID; - const otherPlugins = process.env.E2E_OTHER_PLUGINS ? process.env.E2E_OTHER_PLUGINS.split(",").map((i) => i.trim()) : []; + const otherPlugins = process.env.E2E_OTHER_PLUGINS + ? process.env.E2E_OTHER_PLUGINS.split(",").map((i) => i.trim()) + : []; answers.PLUGIN_IDS = [process.env.E2E_PLUGIN_ID].concat(otherPlugins); answers.DATASOURCE_IDS = process.env.E2E_DATASOURCE_IDS From 48c7752de8871bccda473d41e4b89458a38064de Mon Sep 17 00:00:00 2001 From: yduartep Date: Tue, 23 Sep 2025 15:09:37 +0200 Subject: [PATCH 05/12] ci: fix action errors --- actions/plugins-e2e-tests/action.yml | 31 +++++++++++----------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index 7866add77..098b3fffb 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -13,6 +13,10 @@ inputs: description: "Region of the stack where you want to run the tests" required: true + hg_token: + description: "Hosted grafana token necessary to call HG apis" + required: true + other_plugins: description: "List of other plugins that you want to enable separated by comma" required: false @@ -55,34 +59,25 @@ runs: - uses: google-github-actions/auth@8254fb75a33b976a221574d287e93919e6a36f70 # v2.1.6 id: gcloud-auth - working-directory: ${{ github.workspace }} with: token_format: access_token workload_identity_provider: "projects/304398677251/locations/global/workloadIdentityPools/github/providers/github-provider" service_account: "github-cloud-npm-dev-pkgs@grafanalabs-workload-identity.iam.gserviceaccount.com" - name: NPM registry auth - working-directory: ${{ github.workspace }} run: npx google-artifactregistry-auth --credential-config + working-directory: ${{ github.workspace }} shell: bash - name: Install plugin dependencies - working-directory: ${{ github.workspace }} run: yarn install --immutable --prefer-offline + working-directory: ${{ github.workspace }} shell: bash - name: Build frontend - working-directory: ${{ github.workspace }} run: yarn run build - shell: bash - - - name: setup e2e env - id: get-secrets working-directory: ${{ github.workspace }} - uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # v1.2.0 - with: - repo_secrets: | - HG_TOKEN=hg-ci:token + shell: bash - name: Set plugin secrets as environment variables id: set-env-vars @@ -102,24 +97,23 @@ runs: env: E2E_STACK_SLUG: ${{ inputs.stack_slug }} E2E_ENV: ${{ inputs.env }} - HG_TOKEN: ${{ env.HG_TOKEN }} + HG_TOKEN: ${{ inputs.hg_token }} E2E_PLUGIN_ID: ${{ inputs.plugin_id }} E2E_OTHER_PLUGINS: ${{ inputs.other_plugins }} E2E_DATASOURCE_IDS: ${{ inputs.datasource_ids }} - name: Start server - working-directory: ${{ github.workspace }} run: docker compose up -d --build --quiet-pull --timestamps + working-directory: ${{ github.workspace }} shell: bash - name: Install Playwright Browsers - working-directory: ${{ github.workspace }} run: npx playwright install chromium --with-deps + working-directory: ${{ github.workspace }} shell: bash - name: Run Playwright tests shell: bash - working-directory: ${{ github.workspace }} env: NODE_ENV: production run: | @@ -142,14 +136,14 @@ runs: echo "Grafana is ready!" npx playwright test + working-directory: ${{ github.workspace }} - name: Stop grafana docker - working-directory: ${{ github.workspace }} run: docker compose down + working-directory: ${{ github.workspace }} shell: bash - name: Upload E2E report - working-directory: ${{ github.workspace }} uses: actions/upload-artifact@v4 if: always() with: @@ -158,7 +152,6 @@ runs: retention-days: 30 - name: Upload E2E videos - working-directory: ${{ github.workspace }} uses: actions/upload-artifact@v4 if: always() with: From 7678ac1f8cf9e7a4c78911f2422a62926fc95a0e Mon Sep 17 00:00:00 2001 From: yduartep Date: Tue, 23 Sep 2025 17:35:47 +0200 Subject: [PATCH 06/12] ci: fix tests paths --- actions/plugins-e2e-tests/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index 098b3fffb..2543bfb71 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -87,7 +87,8 @@ runs: SECRETS_JSON: "${{ inputs.plugin-secrets }}" run: | echo "Parsing and setting plugin environment variables..." - echo "$SECRETS_JSON" | jq -r 'to_entries[] | "echo \"\(.key)=\(.value)\" >> $GITHUB_ENV"' + echo "$SECRETS_JSON" | jq -r 'to_entries[] | "echo \"\(.key)=\(.value)\" >> $GITHUB_ENV"' | bash + echo "PLAYWRIGHT_TESTS_GROUP_PATH is ${env.PLAYWRIGHT_TESTS_GROUP_PATH}" echo "Plugin environment variables set." - name: Generate provisioning @@ -135,6 +136,7 @@ runs: done echo "Grafana is ready!" + echo "PLAYWRIGHT_TESTS_GROUP_PATH is ${env.PLAYWRIGHT_TESTS_GROUP_PATH}" npx playwright test working-directory: ${{ github.workspace }} From bbf01a5cbd5cee7495ec2ae9668012c1674cd1ea Mon Sep 17 00:00:00 2001 From: yduartep Date: Tue, 23 Sep 2025 17:44:36 +0200 Subject: [PATCH 07/12] ci: check existence and value of received env var --- actions/plugins-e2e-tests/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index 2543bfb71..d34cc313d 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -88,7 +88,6 @@ runs: run: | echo "Parsing and setting plugin environment variables..." echo "$SECRETS_JSON" | jq -r 'to_entries[] | "echo \"\(.key)=\(.value)\" >> $GITHUB_ENV"' | bash - echo "PLAYWRIGHT_TESTS_GROUP_PATH is ${env.PLAYWRIGHT_TESTS_GROUP_PATH}" echo "Plugin environment variables set." - name: Generate provisioning @@ -113,6 +112,13 @@ runs: working-directory: ${{ github.workspace }} shell: bash + - name: Check playwright tests path + shell: bash + env: + PLAYWRIGHT_TESTS_GROUP_PATH: ${{ env.PLAYWRIGHT_TESTS_GROUP_PATH }} + run: | + echo "PLAYWRIGHT_TESTS_GROUP_PATH is $PLAYWRIGHT_TESTS_GROUP_PATH" + - name: Run Playwright tests shell: bash env: @@ -136,7 +142,6 @@ runs: done echo "Grafana is ready!" - echo "PLAYWRIGHT_TESTS_GROUP_PATH is ${env.PLAYWRIGHT_TESTS_GROUP_PATH}" npx playwright test working-directory: ${{ github.workspace }} From 7e04162ee00b64e1c2da68af34ad2e83b8f23777 Mon Sep 17 00:00:00 2001 From: yduartep Date: Tue, 23 Sep 2025 17:55:22 +0200 Subject: [PATCH 08/12] ci: switch to get common hg token from vault --- actions/plugins-e2e-tests/action.yml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/actions/plugins-e2e-tests/action.yml b/actions/plugins-e2e-tests/action.yml index d34cc313d..17a59a0bd 100644 --- a/actions/plugins-e2e-tests/action.yml +++ b/actions/plugins-e2e-tests/action.yml @@ -13,10 +13,6 @@ inputs: description: "Region of the stack where you want to run the tests" required: true - hg_token: - description: "Hosted grafana token necessary to call HG apis" - required: true - other_plugins: description: "List of other plugins that you want to enable separated by comma" required: false @@ -79,6 +75,13 @@ runs: working-directory: ${{ github.workspace }} shell: bash + - name: Get common secrets + id: get-common-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # v1.2.0 + with: + common_secrets: | + HG_TOKEN=hg-ci:token + - name: Set plugin secrets as environment variables id: set-env-vars if: ${{ inputs.plugin-secrets != '' }} @@ -97,7 +100,7 @@ runs: env: E2E_STACK_SLUG: ${{ inputs.stack_slug }} E2E_ENV: ${{ inputs.env }} - HG_TOKEN: ${{ inputs.hg_token }} + HG_TOKEN: ${{ env.HG_TOKEN }} E2E_PLUGIN_ID: ${{ inputs.plugin_id }} E2E_OTHER_PLUGINS: ${{ inputs.other_plugins }} E2E_DATASOURCE_IDS: ${{ inputs.datasource_ids }} @@ -112,13 +115,6 @@ runs: working-directory: ${{ github.workspace }} shell: bash - - name: Check playwright tests path - shell: bash - env: - PLAYWRIGHT_TESTS_GROUP_PATH: ${{ env.PLAYWRIGHT_TESTS_GROUP_PATH }} - run: | - echo "PLAYWRIGHT_TESTS_GROUP_PATH is $PLAYWRIGHT_TESTS_GROUP_PATH" - - name: Run Playwright tests shell: bash env: From 3898bf1b68a829097d9b40ab30e35a94e871b26d Mon Sep 17 00:00:00 2001 From: yduartep Date: Thu, 25 Sep 2025 11:20:16 +0200 Subject: [PATCH 09/12] ci: add uid to data sources provisioned if not received from provisioning api --- .../plop-templates/docker-compose.hbs.yaml | 23 ++++++++++++ actions/plugins-e2e-tests/plopfile.mjs | 36 +++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml diff --git a/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml b/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml new file mode 100644 index 000000000..ca0393394 --- /dev/null +++ b/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml @@ -0,0 +1,23 @@ +services: + grafana: + environment: + - GF_PLUGINS_PREINSTALL_SYNC={{{GF_PLUGINS_PREINSTALL_SYNC}}} + - GF_GRAFANA_COM_SSO_API_TOKEN={{{GF_GRAFANA_COM_SSO_API_TOKEN}}} + - GF_GRAFANA_COM_URL={{{GF_GRAFANA_COM_URL}}} + - GF_GRAFANA_COM_API_URL={{{GF_GRAFANA_COM_API_URL}}} + + container_name: '{{{GF_PLUGIN_ID}}}' + restart: on-failure + platform: 'linux/amd64' + build: + context: ./.config + args: + grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} + grafana_version: ${GRAFANA_VERSION:-main} + ports: + - 3000:3000/tcp + volumes: + - ./dist:/var/lib/grafana/plugins/{{{GF_PLUGIN_ID}}} + - ./provisioning:/etc/grafana/provisioning + - ./provisioning/grafana.ini:/etc/grafana/grafana.ini + - ./provisioning/license.jwt:/etc/grafana/license.jwt diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs index ce0e061db..e1eaa8d75 100644 --- a/actions/plugins-e2e-tests/plopfile.mjs +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -11,12 +11,44 @@ const DATASOURCES_YAML_FILE = path.join( process.cwd(), "./provisioning/datasources/default.yaml", ); +// TODO check how to get the uid from the provisioning api or call /api/datasources instead +function getUid(dataSource, stackSlug) { + let uid = ''; + switch (dataSource.type) { + case 'prometheus': + if (dataSource.name === `grafanacloud-${stackSlug}-prom`) { + uid = 'grafanacloud-prom'; + } + break; + + case 'loki': + if (dataSource.name === `grafanacloud-${stackSlug}-logs`) { + uid = 'grafanacloud-logs'; + } + break; + + case 'tempo': + if (dataSource.name === `grafanacloud-${stackSlug}-traces`) { + uid = 'grafanacloud-traces'; + } + break; + + case 'alertmanager': + if (dataSource.name === `grafanacloud-${stackSlug}-ngalertmanager`) { + uid = 'grafanacloud-ngalertmanager'; + } + break; + } + return uid; +} -function formatDataSource(dataSource) { +function formatDataSource(dataSource, stackSlug) { if (dataSource) { + const uid = !dataSource.uid ? getUid(dataSource, stackSlug) : dataSource.uid; return { name: dataSource.name, type: dataSource.type, + ...(uid && { uid }), url: dataSource.url, basicAuth: dataSource.basicAuth === 1 || dataSource.basicAuth === true, basicAuthUser: dataSource.basicAuthUser @@ -147,7 +179,7 @@ async function fetchDataSource(stackSlug, env, datasourceName) { const dataSourceWithToken = await response.json(); const dataSourceWithNoEmptyField = removeEmptyProperties(dataSourceWithToken); - return formatDataSource(dataSourceWithNoEmptyField); + return formatDataSource(dataSourceWithNoEmptyField, stackSlug); } catch (error) { console.error( "Error fetching datasource", From 20a95e8afaefe4c4bb658003796502af9c777b74 Mon Sep 17 00:00:00 2001 From: yduartep Date: Thu, 25 Sep 2025 12:18:10 +0200 Subject: [PATCH 10/12] ci: fix format lint --- .../plop-templates/docker-compose.hbs.yaml | 4 ++-- actions/plugins-e2e-tests/plopfile.mjs | 22 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml b/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml index ca0393394..a426e3454 100644 --- a/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml +++ b/actions/plugins-e2e-tests/plop-templates/docker-compose.hbs.yaml @@ -6,9 +6,9 @@ services: - GF_GRAFANA_COM_URL={{{GF_GRAFANA_COM_URL}}} - GF_GRAFANA_COM_API_URL={{{GF_GRAFANA_COM_API_URL}}} - container_name: '{{{GF_PLUGIN_ID}}}' + container_name: "{{{GF_PLUGIN_ID}}}" restart: on-failure - platform: 'linux/amd64' + platform: "linux/amd64" build: context: ./.config args: diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs index e1eaa8d75..565157737 100644 --- a/actions/plugins-e2e-tests/plopfile.mjs +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -13,29 +13,29 @@ const DATASOURCES_YAML_FILE = path.join( ); // TODO check how to get the uid from the provisioning api or call /api/datasources instead function getUid(dataSource, stackSlug) { - let uid = ''; + let uid = ""; switch (dataSource.type) { - case 'prometheus': + case "prometheus": if (dataSource.name === `grafanacloud-${stackSlug}-prom`) { - uid = 'grafanacloud-prom'; + uid = "grafanacloud-prom"; } break; - case 'loki': + case "loki": if (dataSource.name === `grafanacloud-${stackSlug}-logs`) { - uid = 'grafanacloud-logs'; + uid = "grafanacloud-logs"; } break; - case 'tempo': + case "tempo": if (dataSource.name === `grafanacloud-${stackSlug}-traces`) { - uid = 'grafanacloud-traces'; + uid = "grafanacloud-traces"; } break; - case 'alertmanager': + case "alertmanager": if (dataSource.name === `grafanacloud-${stackSlug}-ngalertmanager`) { - uid = 'grafanacloud-ngalertmanager'; + uid = "grafanacloud-ngalertmanager"; } break; } @@ -44,7 +44,9 @@ function getUid(dataSource, stackSlug) { function formatDataSource(dataSource, stackSlug) { if (dataSource) { - const uid = !dataSource.uid ? getUid(dataSource, stackSlug) : dataSource.uid; + const uid = !dataSource.uid + ? getUid(dataSource, stackSlug) + : dataSource.uid; return { name: dataSource.name, type: dataSource.type, From 6e3482d6b03d50b3e1f2d76ea02026357e9bb61f Mon Sep 17 00:00:00 2001 From: yduartep Date: Wed, 1 Oct 2025 09:36:25 +0200 Subject: [PATCH 11/12] ci: use the same function that hg use to generate the uid --- actions/plugins-e2e-tests/plopfile.mjs | 56 +++++++++++++++----------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs index 565157737..2ae92e539 100644 --- a/actions/plugins-e2e-tests/plopfile.mjs +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -11,33 +11,41 @@ const DATASOURCES_YAML_FILE = path.join( process.cwd(), "./provisioning/datasources/default.yaml", ); -// TODO check how to get the uid from the provisioning api or call /api/datasources instead -function getUid(dataSource, stackSlug) { - let uid = ""; - switch (dataSource.type) { - case "prometheus": - if (dataSource.name === `grafanacloud-${stackSlug}-prom`) { - uid = "grafanacloud-prom"; - } - break; - case "loki": - if (dataSource.name === `grafanacloud-${stackSlug}-logs`) { - uid = "grafanacloud-logs"; - } - break; +const gcloudDSPattern = /grafanacloud-(\w+)-([a-z-]+)/; + +/** + * getProvisionedDSType returns the provisioned datasource type and returns an empty string if it doesn't match the pattern and the slug. + * @param datasourceName The full name of the datasource (e.g., "grafanacloud-my-slug-traces"). + * @param slug The expected slug (e.g., "my-slug"). + * @returns The datasource type (e.g., "traces"), or an empty string if criteria are not met. + */ +function getProvisionedDSType(datasourceName, slug) { + const match = gcloudDSPattern.exec(datasourceName); + if (match && match.length >= 3 && match[1] === slug) { + return match[2]; + } + return ''; +} - case "tempo": - if (dataSource.name === `grafanacloud-${stackSlug}-traces`) { - uid = "grafanacloud-traces"; - } - break; +/** + * Creates a predictable UID for Grafana Cloud datasources. + * If the datasource matches the pattern (e.g., grafanacloud--), the UID is simplified to "grafanacloud-"; otherwise, it uses the full name. + * @param dataSource The dataSource provisioned object + * @param stackSlug The expected slug (e.g., "staging"). + * @returns A UID string, guaranteed to be 40 characters or less. + */ +function getUid(dataSource, stackSlug) { + const datasourceName = dataSource.name; + let uid = datasourceName; - case "alertmanager": - if (dataSource.name === `grafanacloud-${stackSlug}-ngalertmanager`) { - uid = "grafanacloud-ngalertmanager"; - } - break; + const provisionedDSType = getProvisionedDSType(datasourceName, stackSlug); + if (provisionedDSType !== '') { + uid = 'grafanacloud-' + provisionedDSType; + } + const maxLength = 40; + if (uid.length > maxLength) { + uid = uid.slice(uid.length - maxLength); } return uid; } From f0bf6b3e6f59235f42a4e72f4ebb10daca69a37c Mon Sep 17 00:00:00 2001 From: yduartep Date: Wed, 1 Oct 2025 11:25:58 +0200 Subject: [PATCH 12/12] ci: validate empty or error response before to generate provisioning files --- actions/plugins-e2e-tests/plopfile.mjs | 120 +++++++++++++++++++++---- 1 file changed, 104 insertions(+), 16 deletions(-) diff --git a/actions/plugins-e2e-tests/plopfile.mjs b/actions/plugins-e2e-tests/plopfile.mjs index 2ae92e539..a469f4937 100644 --- a/actions/plugins-e2e-tests/plopfile.mjs +++ b/actions/plugins-e2e-tests/plopfile.mjs @@ -2,6 +2,36 @@ import fs from "fs"; import yaml from "js-yaml"; import * as path from "path"; +function isError(response) { + if (!response || typeof response !== "object") { + return false; + } + const keys = Object.keys(response); + return keys.includes("code") || keys.includes("message"); +} + +/** + * Checks if a value is null, undefined, an empty array, or an object with no enumerable properties. + * @param {any} value The value to check. + * @returns {boolean} True if the value is empty, otherwise false. + */ +function isEmpty(value) { + // console.log('Check is empty for: ', JSON.stringify(value)); + if (value === null || typeof value === "undefined") { + return true; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === "string") { + return value.length === 0; + } + if (typeof value === "object") { + return Object.keys(value).length === 0; + } + return false; +} + const HG_TOKEN = process.env.HG_TOKEN; const APPS_YAML_FILE = path.join( process.cwd(), @@ -11,6 +41,14 @@ const DATASOURCES_YAML_FILE = path.join( process.cwd(), "./provisioning/datasources/default.yaml", ); +const HG_REGION_SUFFIX_MAP = { + "prod-us-east": "prod-us-east-0", + "prod-eu-west": "prod-eu-west-0", + prod: "prod-us-central-0", + ops: "ops-eu-south-0", + "dev-east": "dev-us-east-0", + "dev-central": "dev-us-central-0", +}; const gcloudDSPattern = /grafanacloud-(\w+)-([a-z-]+)/; @@ -25,7 +63,7 @@ function getProvisionedDSType(datasourceName, slug) { if (match && match.length >= 3 && match[1] === slug) { return match[2]; } - return ''; + return ""; } /** @@ -40,8 +78,8 @@ function getUid(dataSource, stackSlug) { let uid = datasourceName; const provisionedDSType = getProvisionedDSType(datasourceName, stackSlug); - if (provisionedDSType !== '') { - uid = 'grafanacloud-' + provisionedDSType; + if (provisionedDSType !== "") { + uid = "grafanacloud-" + provisionedDSType; } const maxLength = 40; if (uid.length > maxLength) { @@ -75,6 +113,9 @@ function formatDataSource(dataSource, stackSlug) { } function removeEmptyProperties(obj) { + if (!obj || isEmpty(obj)) { + return obj; + } // Check if the input is an object or an array if (Array.isArray(obj)) { // If it's an array, recursively clean each element @@ -115,20 +156,26 @@ function removeEmptyProperties(obj) { return obj; } +function isProdEnvironment(env) { + const envType = env.split("-")[0]; + return envType === "prod"; +} + +/** + * Dynamically generates the HG base API URL based on the region selected. + * @param {string} env The environment region (e.g., "prod-us-east", "dev-central"). + * @returns {string} The constructed base API URL. + */ function getBaseUrlByEnv(env) { - switch (env) { - case "prod-us-east": - return "https://hg-api-prod-us-east-0.grafana.net"; - case "prod": - return "https://hg-api-prod-us-central-0.grafana.net"; - case "ops": - return "https://hg-api-ops-eu-south-0.grafana-ops.net"; - case "dev-east": - return "https://hg-api-dev-us-east-0.grafana-dev.net"; - case "dev-central": - default: - return "https://hg-api-dev-us-central-0.grafana-dev.net"; + const envType = env.split("-")[0]; + + let domainSuffix = "grafana"; + if (["dev", "ops"].includes(envType)) { + domainSuffix = `grafana-${envType}`; } + const regionSuffix = + HG_REGION_SUFFIX_MAP[env] || HG_REGION_SUFFIX_MAP["dev-central"]; + return `https://hg-api-${regionSuffix}.${domainSuffix}.net`; } async function fetchMultipleAppConfigs(stackSlug, env, pluginIds) { @@ -291,11 +338,36 @@ function writeAppsYamlFile(yamlData) { } async function fillAnswers(answers) { + if (isProdEnvironment(answers.ENV)) { + console.error( + "For security reason, you are not allowed to provision plugins locally on production environment.", + ); + process.exit(1); + } + if (!answers.PLUGIN_IDS || answers.PLUGIN_IDS.length === 0) { + console.error( + `No plugin was selected for the stack ${answers.STACK_SLUG} on environment ${answers.ENV}.`, + ); + process.exit(1); + } + if (!answers.DATASOURCE_IDS || answers.DATASOURCE_IDS.length === 0) { + console.error( + `No data source selected for the stack ${answers.STACK_SLUG} on environment ${answers.ENV}.`, + ); + process.exit(1); + } + const appConfigs = await fetchMultipleAppConfigs( answers.STACK_SLUG, answers.ENV, answers.PLUGIN_IDS, ); + if (isError(appConfigs) || isEmpty(appConfigs)) { + console.error( + `No app config found for the stack ${answers.STACK_SLUG} on environment ${answers.ENV}.`, + ); + process.exit(1); + } const yamlAppsData = createAppsYamlFile(); addAppConfigs(yamlAppsData, appConfigs); writeAppsYamlFile(yamlAppsData); @@ -305,6 +377,12 @@ async function fillAnswers(answers) { answers.ENV, answers.DATASOURCE_IDS, ); + if (isError(dataSourceConfigs) || isEmpty(dataSourceConfigs)) { + console.error( + `The data sources ${answers.DATASOURCE_IDS} cannot be loaded from the stack ${answers.STACK_SLUG} on environment ${answers.ENV}.`, + ); + process.exit(1); + } const yamlDataSourcesData = createDataSourcesYamlFile(); addDataSourceConfigs(yamlDataSourcesData, dataSourceConfigs); writeDataSourcesYamlFile(yamlDataSourcesData); @@ -314,6 +392,12 @@ async function fillAnswers(answers) { answers.ENV, answers.GF_PLUGIN_ID, ); + if (isError(grafanaConfig) || isEmpty(grafanaConfig)) { + console.error( + `No grafana config found for plugin ${answers.GF_PLUGIN_ID} on the stack ${answers.STACK_SLUG} and environment ${answers.ENV}.`, + ); + process.exit(1); + } answers.GF_GRAFANA_COM_SSO_API_TOKEN = grafanaConfig.hosted_grafana.hg_auth_token; @@ -353,6 +437,10 @@ export default function (plop) { process.exit(1); } answers.STACK_SLUG = process.env.E2E_STACK_SLUG; + + if (isProdEnvironment(process.env.E2E_ENV)) { + throw "For security reason, you are not allowed to provision locally production environment."; + } answers.ENV = process.env.E2E_ENV; answers.GF_PLUGIN_ID = process.env.E2E_PLUGIN_ID; @@ -371,7 +459,7 @@ export default function (plop) { return "Remote Provisioning data loaded successfully for e2e tests."; } catch (error) { console.error("Failed to load Remote Provisioning:", error.message); - return "Failed to load Remote Provisioning data"; + throw error; } }, {