E2E: Rework the runner (#105712)

pull/106400/head
Mariell Hoversholm 1 month ago committed by GitHub
parent fd7b6091a2
commit 185d1a1530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      .github/CODEOWNERS
  2. 115
      .github/workflows/pr-e2e-tests.yml
  3. 39
      .github/workflows/run-e2e-suite.yml
  4. 1
      .github/zizmor.yml
  5. 351
      e2e/main.go
  6. 6
      e2e/run-suite
  7. 1
      go.mod
  8. 2
      go.sum
  9. 6
      pkg/build/daggerbuild/arguments/grafana.go
  10. 144
      pkg/build/e2e/main.go
  11. 15
      pkg/build/e2e/run.go
  12. 34
      pkg/build/e2e/service.go
  13. 1
      pkg/build/go.mod
  14. 2
      pkg/build/go.sum

@ -836,7 +836,6 @@ embed.go @grafana/grafana-as-code
/.github/workflows/frontend-lint.yml @grafana/grafana-frontend-platform /.github/workflows/frontend-lint.yml @grafana/grafana-frontend-platform
/.github/workflows/analytics-events-report.yml @grafana/grafana-frontend-platform /.github/workflows/analytics-events-report.yml @grafana/grafana-frontend-platform
/.github/workflows/pr-e2e-tests.yml @grafana/grafana-developer-enablement-squad /.github/workflows/pr-e2e-tests.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/run-e2e-suite.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/skye-add-to-project.yml @grafana/grafana-frontend-platform /.github/workflows/skye-add-to-project.yml @grafana/grafana-frontend-platform
/.github/zizmor.yml @grafana/grafana-developer-enablement-squad /.github/zizmor.yml @grafana/grafana-developer-enablement-squad
/.github/license_finder.yaml @bergquist /.github/license_finder.yaml @bergquist

@ -39,39 +39,98 @@ jobs:
retention-days: 1 retention-days: 1
name: ${{ steps.artifact.outputs.artifact }} name: ${{ steps.artifact.outputs.artifact }}
path: grafana.tar.gz path: grafana.tar.gz
e2e-matrix:
build-e2e-runner:
name: Build E2E test runner
runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
name: ${{ matrix.suite }} outputs:
strategy: artifact: ${{ steps.artifact.outputs.artifact }}
fail-fast: false steps:
matrix: - uses: actions/checkout@v4
suite: with:
- various-suite persist-credentials: false
- dashboards-suite - name: Setup Go
- smoke-tests-suite uses: actions/setup-go@v5
- panels-suite with:
go-version-file: go.mod
cache: ${{ !github.event.pull_request.head.repo.fork }}
- name: Build E2E test runner
id: artifact
run: |
# We want a static binary, so we need to set CGO_ENABLED=0
CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/
echo "artifact=e2e-runner-${{github.run_number}}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
id: upload
with:
retention-days: 1
name: ${{ steps.artifact.outputs.artifact }}
path: e2e-runner
run-e2e-tests:
needs: needs:
- build-grafana - build-grafana
uses: ./.github/workflows/run-e2e-suite.yml - build-e2e-runner
with:
package: ${{ needs.build-grafana.outputs.artifact }}
suite: ${{ matrix.suite }}
e2e-matrix-old-arch:
permissions:
contents: read
name: ${{ matrix.suite }} (old arch)
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
suite: include:
- old-arch/various-suite - suite: various-suite
- old-arch/dashboards-suite path: e2e/various-suite
- old-arch/smoke-tests-suite - suite: dashboards-suite
- old-arch/panels-suite path: e2e/dashboards-suite
needs: - suite: smoke-tests-suite
- build-grafana path: e2e/smoke-tests-suite
uses: ./.github/workflows/run-e2e-suite.yml - suite: panels-suite
path: e2e/panels-suite
- suite: various-suite (old arch)
path: e2e/old-arch/various-suite
flags: --flags="--env dashboardScene=false"
- suite: dashboards-suite (old arch)
path: e2e/old-arch/dashboards-suite
flags: --flags="--env dashboardScene=false"
- suite: smoke-tests-suite (old arch)
path: e2e/old-arch/smoke-tests-suite
flags: --flags="--env dashboardScene=false"
- suite: panels-suite (old arch)
path: e2e/old-arch/panels-suite
flags: --flags="--env dashboardScene=false"
name: ${{ matrix.suite }}
runs-on: ubuntu-latest-8-cores
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: ${{ needs.build-grafana.outputs.artifact }}
- uses: actions/download-artifact@v4
with:
name: ${{ needs.build-e2e-runner.outputs.artifact }}
- name: chmod +x
run: chmod +x ./e2e-runner
- name: Run E2E tests
uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e
with: with:
package: ${{ needs.build-grafana.outputs.artifact }} verb: run
suite: ${{ matrix.suite }} args: go run ./pkg/build/e2e --package=grafana.tar.gz
--suite=${{ matrix.path }}
${{ matrix.flags }}
- name: Set suite name
id: set-suite-name
if: success() || failure()
env:
SUITE: ${{ matrix.path }}
run: |
echo "suite=$(echo "$SUITE" | sed 's/\//-/g')" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: success() || failure()
with:
name: ${{ steps.set-suite-name.outputs.suite }}-${{ github.run_number }}
path: videos
retention-days: 1

@ -1,39 +0,0 @@
name: e2e suite
on:
workflow_call:
inputs:
package:
type: string
required: true
suite:
type: string
required: true
jobs:
main:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: ${{ inputs.package }}
- uses: dagger/dagger-for-github@e47aba410ef9bb9ed81a4d2a97df31061e5e842e
with:
verb: run
args: go run ./pkg/build/e2e --package=grafana.tar.gz --suite=${{ inputs.suite }}
- name: Set suite name
id: set-suite-name
if: success() || failure()
env:
SUITE: ${{ inputs.suite }}
run: |
echo "suite=$(echo "$SUITE" | sed 's/\//-/g')" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: success() || failure()
with:
name: e2e-${{ steps.set-suite-name.outputs.suite }}-${{github.run_number}}
path: videos
retention-days: 1

@ -21,6 +21,7 @@ rules:
- pr-frontend-unit-tests.yml - pr-frontend-unit-tests.yml
- pr-test-integration.yml - pr-test-integration.yml
- publish-kinds-release.yml - publish-kinds-release.yml
- pr-e2e-tests.yml
dangerous-triggers: dangerous-triggers:
ignore: ignore:
- auto-milestone.yml - auto-milestone.yml

@ -0,0 +1,351 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/urfave/cli/v3"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := Run().Run(ctx, os.Args); err != nil {
cancel()
fmt.Println(err)
os.Exit(1)
}
}
func Run() *cli.Command {
var suitePath string
return &cli.Command{
Name: "e2e",
Usage: "Run the test suite",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "command",
Usage: "Cypress command to run. 'open' can be useful for development (enum: run, open)",
Value: "run",
Validator: func(s string) error {
if s != "run" && s != "open" {
return fmt.Errorf("invalid command: %s, must be 'run' or 'open'", s)
}
return nil
},
},
&cli.StringFlag{
Name: "browser",
Usage: "Browser to run tests with (e.g.: chrome, electron)",
Value: "chrome",
},
&cli.StringFlag{
Name: "grafana-base-url",
Usage: "Base URL for Grafana",
Value: "http://localhost:3001",
},
&cli.BoolFlag{
Name: "cypress-video",
Usage: "Enable Cypress video recordings",
Value: false,
},
&cli.BoolFlag{
Name: "smtp-plugin",
Usage: "Enable SMTP plugin",
Value: false,
},
&cli.BoolFlag{
Name: "benchmark-plugin",
Usage: "Enable Benchmark plugin",
Value: false,
},
&cli.BoolFlag{
Name: "slowmo",
Usage: "Slow down the test run",
Value: false,
},
&cli.StringSliceFlag{
Name: "env",
Usage: "Additional Cypress environment variables to set (format: KEY=VALUE)",
Validator: func(s []string) error {
pattern := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*`)
for _, v := range s {
if !pattern.MatchString(v) {
return fmt.Errorf("invalid environment variable format: %s, must be KEY=VALUE", v)
}
}
return nil
},
},
&cli.StringSliceFlag{
Name: "parameters",
Usage: "Additional parameters to pass to the Cypress command (e.g. --headed)",
},
&cli.DurationFlag{
Name: "timeout",
Usage: "Timeout for the Cypress command (precision: milliseconds)",
Value: time.Second * 30,
Validator: func(d time.Duration) error {
if d < 0 {
return fmt.Errorf("timeout must be a positive duration")
}
if d.Round(time.Millisecond) != d {
return fmt.Errorf("timeout must be a whole number of milliseconds")
}
return nil
},
},
&cli.StringFlag{
Name: "timezone",
Usage: "Timezone to set for the Cypress run (e.g. 'America/New_York')",
Value: "Pacific/Honolulu",
},
&cli.BoolFlag{
Name: "start-grafana",
Usage: "Start and wait for Grafana before running the tests",
Value: true,
Category: "Grafana Server",
},
&cli.StringFlag{
Name: "license-path",
Usage: "Path to the Grafana Enterprise license file (optional; requires --start-grafana)",
Value: "",
TakesFile: true,
Category: "Grafana Server",
},
&cli.BoolFlag{
Name: "image-renderer",
Usage: "Install the image renderer plugin (requires --start-grafana)",
Category: "Grafana Server",
},
&cli.StringFlag{
Name: "suite",
Usage: "Path to the suite to run (e.g. './e2e/dashboards-suite')",
TakesFile: true,
Required: true,
Destination: &suitePath,
},
},
Action: runAction,
}
}
func runAction(ctx context.Context, c *cli.Command) error {
suitePath := c.String("suite")
suitePath, err := normalisePath(suitePath)
if err != nil {
return fmt.Errorf("failed to normalise suite path: %w", err)
}
repoRoot, err := gitRepoRoot(ctx, suitePath)
if err != nil {
return fmt.Errorf("failed to get git repo root: %w", err)
}
screenshotsFolder := path.Join(suitePath, "screenshots")
videosFolder := path.Join(suitePath, "videos")
fileServerFolder := path.Join(repoRoot, "e2e", "cypress")
fixturesFolder := path.Join(fileServerFolder, "fixtures")
downloadsFolder := path.Join(fileServerFolder, "downloads")
benchmarkPluginResultsFolder := path.Join(suitePath, "benchmark-results")
reporter := path.Join(repoRoot, "e2e", "log-reporter.js")
env := map[string]string{
"BENCHMARK_PLUGIN_ENABLED": fmt.Sprintf("%t", c.Bool("benchmark-plugin")),
"SMTP_PLUGIN_ENABLED": fmt.Sprintf("%t", c.Bool("smtp-plugin")),
"BENCHMARK_PLUGIN_RESULTS_FOLDER": benchmarkPluginResultsFolder,
"SLOWMO": "0",
"BASE_URL": c.String("grafana-base-url"),
}
for _, v := range c.StringSlice("env") {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid environment variable format: %s, must be KEY=VALUE", v)
}
env[parts[0]] = parts[1]
}
cypressConfig := map[string]string{
"screenshotsFolder": screenshotsFolder,
"fixturesFolder": fixturesFolder,
"videosFolder": videosFolder,
"downloadsFolder": downloadsFolder,
"fileServerFolder": fileServerFolder,
"reporter": reporter,
"specPattern": path.Join(suitePath, "*.spec.ts"),
"defaultCommandTimeout": fmt.Sprintf("%d", c.Duration("timeout").Milliseconds()),
"viewportWidth": "1920",
"viewportHeight": "1080",
"trashAssetsBeforeRuns": "false",
"baseUrl": c.String("grafana-base-url"),
"video": fmt.Sprintf("%t", c.Bool("cypress-video")),
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if c.Bool("start-grafana") {
startServerPath := path.Join(repoRoot, "scripts", "grafana-server", "start-server")
waitForGrafanaPath := path.Join(repoRoot, "scripts", "grafana-server", "wait-for-grafana")
go func() {
var args []string
if c.String("license-path") != "" {
args = append(args, c.String("license-path"))
}
//nolint:gosec
cmd := exec.CommandContext(ctx, startServerPath, args...)
cmd.Dir = repoRoot
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone")))
if c.Bool("image-renderer") {
cmd.Env = append(cmd.Env, "INSTALL_IMAGE_RENDERER=true")
}
cmd.Stdout = prefixGrafana(os.Stdout)
cmd.Stderr = prefixGrafana(os.Stderr)
cmd.Stdin = nil
if err := cmd.Run(); err != nil {
fmt.Println("Error running Grafana:", err)
}
}()
//nolint:gosec
cmd := exec.CommandContext(ctx, waitForGrafanaPath)
cmd.Dir = repoRoot
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone")))
cmd.Stdout = prefixGrafana(os.Stdout)
cmd.Stderr = prefixGrafana(os.Stderr)
cmd.Stdin = nil
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to wait for Grafana: %w", err)
}
}
args := []string{"run", "cypress", c.String("command"),
"--env", joinCypressCfg(env),
"--config", joinCypressCfg(cypressConfig),
"--browser", c.String("browser")}
args = append(args, c.StringSlice("parameters")...)
//nolint:gosec
cmd := exec.CommandContext(ctx, "yarn", args...)
cmd.Dir = repoRoot
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", c.String("timezone")))
cmd.Stdout = prefixCypress(os.Stdout)
cmd.Stderr = prefixCypress(os.Stderr)
cmd.Stdin = os.Stdin
return cmd.Run()
}
func gitRepoRoot(ctx context.Context, dir string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git repo root: %w", err)
}
p := strings.TrimSpace(string(out))
p, err = normalisePath(p)
if err != nil {
return "", fmt.Errorf("failed to normalise git repo root path: %w", err)
}
return p, nil
}
func normalisePath(p string) (string, error) {
absPath, err := filepath.Abs(p)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
return path.Clean(filepath.ToSlash(absPath)), nil
}
func joinCypressCfg(cfg map[string]string) string {
config := make([]string, 0, len(cfg))
for k, v := range cfg {
config = append(config, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(config, ",")
}
const (
resetColor = "\033[0m"
yellowColor = "\033[0;33m"
cyanColor = "\033[0;36m"
)
func prefixCypress(w io.Writer) io.Writer {
if _, ok := os.LookupEnv("CI"); ok {
return w
}
return newWrappingOutput(cyanColor+"Cypress: ", resetColor, w)
}
func prefixGrafana(w io.Writer) io.Writer {
if _, ok := os.LookupEnv("CI"); ok {
return w
}
return newWrappingOutput(yellowColor+"Grafana: ", resetColor, w)
}
var _ io.Writer = (*wrappingOutput)(nil)
type wrappingOutput struct {
prefix string
suffix string
mu *sync.Mutex
inner io.Writer
writtenPrefix bool
}
func newWrappingOutput(prefix, suffix string, inner io.Writer) *wrappingOutput {
return &wrappingOutput{
prefix: prefix,
suffix: suffix,
mu: &sync.Mutex{},
inner: inner,
}
}
func (p *wrappingOutput) Write(b []byte) (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
for line := range bytes.Lines(b) {
if !p.writtenPrefix {
if _, err := p.inner.Write([]byte(p.prefix)); err != nil {
return 0, err
}
p.writtenPrefix = true
}
if _, err := p.inner.Write(line); err != nil {
return 0, err
}
if bytes.HasSuffix(line, []byte("\n")) {
p.writtenPrefix = false
if _, err := p.inner.Write([]byte(p.suffix)); err != nil {
return 0, err
}
}
}
return len(b), nil
}

@ -26,7 +26,7 @@ declare -A env=(
) )
testFilesForSingleSuite="*.spec.ts" testFilesForSingleSuite="*.spec.ts"
rootForEnterpriseSuite="./e2e/extensions-suite" rootForEnterpriseSuite="./e2e/extensions"
rootForOldArch="./e2e/old-arch" rootForOldArch="./e2e/old-arch"
rootForKubernetesDashboards="./e2e/dashboards-suite" rootForKubernetesDashboards="./e2e/dashboards-suite"
rootForSearchDashboards="./e2e/dashboards-search-suite" rootForSearchDashboards="./e2e/dashboards-search-suite"
@ -45,6 +45,7 @@ declare -A cypressConfig=(
[trashAssetsBeforeRuns]=false [trashAssetsBeforeRuns]=false
[reporter]=./e2e/log-reporter.js [reporter]=./e2e/log-reporter.js
[baseUrl]=${BASE_URL:-"http://$HOST:$PORT"} [baseUrl]=${BASE_URL:-"http://$HOST:$PORT"}
[video]=${CYPRESS_VIDEO:-false}
) )
case "$1" in case "$1" in
@ -70,8 +71,6 @@ case "$1" in
"enterprise") "enterprise")
echo "Enterprise" echo "Enterprise"
env[SMTP_PLUGIN_ENABLED]=true env[SMTP_PLUGIN_ENABLED]=true
CLEANUP="rm -rf ./e2e/extensions-suite"
SETUP="cp -Lr ./e2e/extensions ./e2e/extensions-suite"
enterpriseSuite=$(basename "${args[1]}") enterpriseSuite=$(basename "${args[1]}")
case "$2" in case "$2" in
"debug") "debug")
@ -87,7 +86,6 @@ case "$1" in
;; ;;
esac esac
cypressConfig[specPattern]=$rootForEnterpriseSuite/$enterpriseSuite/*-suite/*.spec.ts cypressConfig[specPattern]=$rootForEnterpriseSuite/$enterpriseSuite/*-suite/*.spec.ts
$CLEANUP && $SETUP
;; ;;
"") "")
;; ;;

@ -156,6 +156,7 @@ require (
github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // @grafana/grafana-backend-group github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // @grafana/grafana-backend-group
github.com/urfave/cli v1.22.16 // indirect; @grafana/grafana-backend-group github.com/urfave/cli v1.22.16 // indirect; @grafana/grafana-backend-group
github.com/urfave/cli/v2 v2.27.6 // @grafana/grafana-backend-group github.com/urfave/cli/v2 v2.27.6 // @grafana/grafana-backend-group
github.com/urfave/cli/v3 v3.3.3 // @grafana/grafana-backend-group
github.com/wk8/go-ordered-map v1.0.0 // @grafana/grafana-backend-group github.com/wk8/go-ordered-map v1.0.0 // @grafana/grafana-backend-group
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // @grafana/grafana-operator-experience-squad github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // @grafana/grafana-operator-experience-squad

@ -2442,6 +2442,8 @@ github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs=

@ -217,12 +217,12 @@ func enterpriseDirectory(ctx context.Context, opts *pipeline.ArgumentOpts) (any,
return nil, fmt.Errorf("error initializing grafana directory: %w", err) return nil, fmt.Errorf("error initializing grafana directory: %w", err)
} }
src, err := cloneOrMount(ctx, opts.Client, o.EnterpriseDir, o.EnterpriseRepo, o.EnterpriseRef, o.GitHubToken) clone, err := cloneOrMount(ctx, opts.Client, o.EnterpriseDir, o.EnterpriseRepo, o.EnterpriseRef, o.GitHubToken)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("error cloning or mounting Grafana Enterprise directory: %w", err)
} }
return InitializeEnterprise(opts.Client, grafanaDir.(*dagger.Directory), src), nil return InitializeEnterprise(opts.Client, grafanaDir.(*dagger.Directory), clone), nil
} }
var GrafanaDirectoryFlags = []cli.Flag{ var GrafanaDirectoryFlags = []cli.Flag{

@ -2,71 +2,173 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal"
"path"
"dagger.io/dagger" "dagger.io/dagger"
"github.com/urfave/cli/v3"
) )
func main() { func main() {
var ( ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
ctx = context.Background() defer cancel()
grafanaPath = flag.String("grafana-dir", ".", "Path to cloned grafana repo")
targzPath = flag.String("package", "grafana.tar.gz", "Path to grafana tar.gz package") if err := NewApp().Run(ctx, os.Args); err != nil {
suite = flag.String("suite", "", "e2e suite name (used in arg to run-suite script)") cancel()
) fmt.Println(err)
flag.Parse() os.Exit(1)
}
}
func NewApp() *cli.Command {
return &cli.Command{
Name: "e2e",
Usage: "Run the E2E tests for Grafana",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "suite",
Usage: "E2E test suite path (e.g. e2e/various-suite)",
Validator: mustBeDir("suite"),
TakesFile: true,
Required: true,
},
&cli.StringFlag{
Name: "grafana-dir",
Usage: "Path to the grafana/grafana clone directory",
Value: ".",
Validator: mustBeDir("grafana-dir"),
TakesFile: true,
},
&cli.StringFlag{
Name: "package",
Usage: "Path to the grafana tar.gz package",
Value: "grafana.tar.gz",
Validator: mustBeFile("package", false),
TakesFile: true,
},
&cli.StringFlag{
Name: "license",
Usage: "Path to the Grafana Enterprise license file (optional)",
Validator: mustBeFile("license", true),
TakesFile: true,
},
&cli.StringFlag{
Name: "flags",
Usage: "Flags to pass through to the e2e runner",
},
&cli.BoolFlag{
Name: "image-renderer",
Usage: "Install the image renderer plugin",
Value: false,
},
},
Action: run,
}
}
func run(ctx context.Context, cmd *cli.Command) error {
grafanaDir := cmd.String("grafana-dir")
suite := cmd.String("suite")
targzPath := cmd.String("package")
licensePath := cmd.String("license")
imageRenderer := cmd.Bool("image-renderer")
runnerFlags := cmd.String("flags")
d, err := dagger.Connect(ctx) d, err := dagger.Connect(ctx)
if err != nil { if err != nil {
panic(err) return fmt.Errorf("failed to connect to Dagger: %w", err)
} }
yarnCache := d.CacheVolume("yarn") yarnCache := d.CacheVolume("yarn")
log.Println("grafana dir:", *grafanaPath) log.Println("grafana dir:", grafanaDir)
log.Println("targz:", *targzPath) log.Println("targz:", targzPath)
log.Println("license path:", licensePath)
grafana := d.Host().Directory(".", dagger.HostDirectoryOpts{ grafana := d.Host().Directory(".", dagger.HostDirectoryOpts{
Exclude: []string{".git", "node_modules", "*.tar.gz"}, Exclude: []string{"node_modules", "*.tar.gz"},
}) })
targz := d.Host().File(targzPath)
targz := d.Host().File("grafana.tar.gz") var license *dagger.File
if licensePath != "" {
license = d.Host().File(licensePath)
}
svc, err := GrafanaService(ctx, d, GrafanaServiceOpts{ svc, err := GrafanaService(ctx, d, GrafanaServiceOpts{
GrafanaDir: grafana, GrafanaDir: grafana,
GrafanaTarGz: targz, GrafanaTarGz: targz,
YarnCache: yarnCache, YarnCache: yarnCache,
License: license,
InstallImageRenderer: imageRenderer,
}) })
if err != nil { if err != nil {
panic(err) return fmt.Errorf("failed to create Grafana service: %w", err)
} }
videosDir := fmt.Sprintf("/src/e2e/%s/videos", *suite) videosDir := path.Join("/src", suite, "videos")
// *spec.ts.mp4 // *spec.ts.mp4
c := RunSuite(d, svc, grafana, yarnCache, *suite) c := RunSuite(d, svc, grafana, yarnCache, suite, runnerFlags)
c, err = c.Sync(ctx) c, err = c.Sync(ctx)
if err != nil { if err != nil {
log.Fatalf("error running dagger: %s", err) return fmt.Errorf("failed to run e2e test suite: %w", err)
} }
code, err := c.ExitCode(ctx) code, err := c.ExitCode(ctx)
if err != nil { if err != nil {
log.Fatalf("error getting exit code: %s", err) return fmt.Errorf("failed to get exit code of e2e test suite: %w", err)
} }
log.Println("exit code:", code) log.Println("exit code:", code)
// No sync error; export the videos dir // No sync error; export the videos dir
if _, err := c.Directory(videosDir).Export(ctx, "videos"); err != nil { if _, err := c.Directory(videosDir).Export(ctx, "videos"); err != nil {
log.Fatalf("error getting videos: %s", err) return fmt.Errorf("failed to export videos directory: %w", err)
} }
if code != 0 { if code != 0 {
log.Printf("tests failed: exit code %d", code) return fmt.Errorf("e2e tests failed with exit code %d", code)
}
log.Println("e2e tests completed successfully")
return nil
}
func mustBeFile(arg string, emptyOk bool) func(string) error {
return func(s string) error {
if s == "" {
if emptyOk {
return nil
}
return cli.Exit(arg+" cannot be empty", 1)
}
stat, err := os.Stat(s)
if err != nil {
return cli.Exit(arg+" does not exist or cannot be read: "+s, 1)
}
if stat.IsDir() {
return cli.Exit(arg+" must be a file, not a directory: "+s, 1)
}
return nil
} }
}
os.Exit(code) func mustBeDir(arg string) func(string) error {
return func(s string) error {
if s == "" {
return cli.Exit(arg+" cannot be empty", 1)
}
stat, err := os.Stat(s)
if err != nil {
return cli.Exit(arg+" does not exist or cannot be read: "+s, 1)
}
if !stat.IsDir() {
return cli.Exit(arg+" must be a directory: "+s, 1)
}
return nil
}
} }

@ -6,17 +6,14 @@ import (
"dagger.io/dagger" "dagger.io/dagger"
) )
func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite string) *dagger.Container { func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container {
command := fmt.Sprintf(
"./e2e-runner --start-grafana=false --cypress-video"+
" --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags)
return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache). return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache).
WithWorkdir("/src"). WithWorkdir("/src").
WithEnvVariable("HOST", "grafana").
WithEnvVariable("PORT", "3001").
WithServiceBinding("grafana", svc). WithServiceBinding("grafana", svc).
WithExec([]string{"yarn", "install", "--immutable"}). WithExec([]string{"yarn", "install", "--immutable"}).
WithExec([]string{ WithExec([]string{"/bin/bash", "-c", command}, dagger.ContainerWithExecOpts{Expect: dagger.ReturnTypeAny})
"/bin/bash", "-c",
fmt.Sprintf("./e2e/run-suite %s true", suite),
}, dagger.ContainerWithExecOpts{
Expect: dagger.ReturnTypeAny,
})
} }

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"strings" "strings"
"dagger.io/dagger" "dagger.io/dagger"
@ -25,6 +26,8 @@ type GrafanaServiceOpts struct {
GrafanaDir *dagger.Directory GrafanaDir *dagger.Directory
GrafanaTarGz *dagger.File GrafanaTarGz *dagger.File
YarnCache *dagger.CacheVolume YarnCache *dagger.CacheVolume
License *dagger.File
InstallImageRenderer bool
} }
func Frontend(src *dagger.Directory) *dagger.Directory { func Frontend(src *dagger.Directory) *dagger.Directory {
@ -71,8 +74,9 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp
WithExec([]string{"yarn", "install", "--immutable"}). WithExec([]string{"yarn", "install", "--immutable"}).
WithExec([]string{"yarn", "e2e:plugin:build"}) WithExec([]string{"yarn", "e2e:plugin:build"})
svc := d.Container().From("alpine"). // _/ubuntu:latest sticks to latest LTS.
WithExec([]string{"apk", "add", "bash"}). // We need ubuntu to support the image renderer plugin, which assumes glibc.
container := d.Container().From("ubuntu:latest").
WithMountedFile("/src/grafana.tar.gz", opts.GrafanaTarGz). WithMountedFile("/src/grafana.tar.gz", opts.GrafanaTarGz).
WithExec([]string{"mkdir", "-p", "/src/grafana"}). WithExec([]string{"mkdir", "-p", "/src/grafana"}).
WithExec([]string{"tar", "--strip-components=1", "-xzf", "/src/grafana.tar.gz", "-C", "/src/grafana"}). WithExec([]string{"tar", "--strip-components=1", "-xzf", "/src/grafana.tar.gz", "-C", "/src/grafana"}).
@ -84,8 +88,30 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp
WithEnvVariable("GF_APP_MODE", "development"). WithEnvVariable("GF_APP_MODE", "development").
WithEnvVariable("GF_SERVER_HTTP_PORT", "3001"). WithEnvVariable("GF_SERVER_HTTP_PORT", "3001").
WithEnvVariable("GF_SERVER_ROUTER_LOGGING", "1"). WithEnvVariable("GF_SERVER_ROUTER_LOGGING", "1").
WithExposedPort(3001). WithExposedPort(3001)
AsService(dagger.ContainerAsServiceOpts{Args: []string{"bash", "-x", "scripts/grafana-server/start-server"}})
var licenseArg string
if opts.License != nil {
container = container.WithMountedFile("/src/license.jwt", opts.License)
licenseArg = "/src/license.jwt"
}
if opts.InstallImageRenderer {
container = container.WithEnvVariable("INSTALL_IMAGE_RENDERER", "true").
WithExec([]string{"apt-get", "update"}).
WithExec([]string{"apt-get", "install", "-y", "ca-certificates"})
}
// We add all GF_ environment variables to allow for overriding Grafana configuration.
// It is unlikely the runner has any such otherwise.
for _, env := range os.Environ() {
if strings.HasPrefix(env, "GF_") {
parts := strings.SplitN(env, "=", 2)
container = container.WithEnvVariable(parts[0], parts[1])
}
}
svc := container.AsService(dagger.ContainerAsServiceOpts{Args: []string{"bash", "-x", "scripts/grafana-server/start-server", licenseArg}})
return svc, nil return svc, nil
} }

@ -82,6 +82,7 @@ require (
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/google/go-github/v70 v70.0.0 github.com/google/go-github/v70 v70.0.0
github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/quasilyte/go-ruleguard/dsl v0.3.22
github.com/urfave/cli/v3 v3.3.3
) )
require ( require (

@ -251,6 +251,8 @@ github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=

Loading…
Cancel
Save