github.com/grafana/pyroscope@v1.18.0/examples/examples_test.go (about)

     1  //go:build examples
     2  
     3  package examples
     4  
     5  import (
     6  	"bufio"
     7  	"bytes"
     8  	"context"
     9  	"crypto/sha256"
    10  	"encoding/json"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"path/filepath"
    16  	"strings"
    17  	"syscall"
    18  	"testing"
    19  	"time"
    20  
    21  	"os/exec"
    22  
    23  	"github.com/stretchr/testify/require"
    24  	"gopkg.in/yaml.v3"
    25  )
    26  
    27  const (
    28  	timeoutPerExample     = 10 * time.Minute
    29  	durationToStayRunning = 5 * time.Second
    30  )
    31  
    32  type env struct {
    33  	dir  string // project dir of docker-compose
    34  	path string // path to docker-compose file
    35  }
    36  
    37  type status struct {
    38  	Name  string `json:"Name"`
    39  	State string `json:"State"`
    40  }
    41  
    42  func (e *env) projectName() string {
    43  	h := sha256.New()
    44  	_, _ = h.Write([]byte(e.dir))
    45  	return fmt.Sprintf("%s_%x", filepath.Base(e.dir), h.Sum(nil)[0:2])
    46  }
    47  
    48  func (e *env) newCmd(ctx context.Context, args ...string) *exec.Cmd {
    49  	c := exec.CommandContext(
    50  		ctx,
    51  		"docker",
    52  		append([]string{
    53  			"compose",
    54  			"--file", e.path,
    55  			"--project-directory", e.dir,
    56  			"--project-name", e.projectName(),
    57  		}, args...)...)
    58  	return c
    59  }
    60  
    61  func (e *env) newCmdWithOutputCapture(t testing.TB, ctx context.Context, args ...string) *exec.Cmd {
    62  	c := e.newCmd(ctx, args...)
    63  	stdout, err := c.StdoutPipe()
    64  	require.NoError(t, err)
    65  	go func() {
    66  		scanner := bufio.NewScanner(stdout)
    67  		for scanner.Scan() {
    68  			t.Log(scanner.Text())
    69  		}
    70  	}()
    71  
    72  	stderr, err := c.StderrPipe()
    73  	require.NoError(t, err)
    74  	go func() {
    75  		scanner := bufio.NewScanner(stderr)
    76  		for scanner.Scan() {
    77  			t.Log("STDERR: " + scanner.Text())
    78  		}
    79  	}()
    80  
    81  	return c
    82  }
    83  
    84  func (e *env) containerStatus(ctx context.Context) ([]status, error) {
    85  	data, err := e.newCmd(ctx, "ps", "--all", "--format", "json").Output()
    86  	if err != nil {
    87  		return nil, err
    88  	}
    89  
    90  	var stats []status
    91  	dec := json.NewDecoder(bytes.NewReader(data))
    92  	for {
    93  		var s status
    94  		err := dec.Decode(&s)
    95  		if errors.Is(err, io.EOF) {
    96  			break
    97  		}
    98  		if err != nil {
    99  			return nil, err
   100  		}
   101  		stats = append(stats, s)
   102  	}
   103  
   104  	return stats, nil
   105  }
   106  
   107  func (e *env) containersAllRunning(ctx context.Context) error {
   108  	status, err := e.containerStatus(ctx)
   109  	if err != nil {
   110  		return err
   111  	}
   112  
   113  	var errs []error
   114  	for _, s := range status {
   115  		if s.State != "running" {
   116  			errs = append(errs, fmt.Errorf("container %s is not running", s.Name))
   117  		}
   118  	}
   119  
   120  	return errors.Join(errs...)
   121  }
   122  
   123  // removeExposedPorts removes ports from services which expose fixed ports. This will break once there is an overlap of ports. This will instead use random ports allocated by docker-compose.
   124  func (e *env) removeExposedPorts(t testing.TB) *env {
   125  
   126  	var obj map[interface{}]interface{}
   127  
   128  	body, err := os.ReadFile(e.path)
   129  	if err != nil {
   130  		require.NoError(t, err)
   131  	}
   132  
   133  	if err := yaml.Unmarshal(body, &obj); err != nil {
   134  		require.NoError(t, err)
   135  	}
   136  
   137  	changed := false
   138  
   139  	for key, value := range obj {
   140  		if key.(string) == "services" {
   141  			services, ok := value.(map[string]interface{})
   142  			if !ok {
   143  				require.NoError(t, fmt.Errorf("services is not a map[string]interface{}"))
   144  			}
   145  			for serviceName, service := range services {
   146  				params, ok := service.(map[string]interface{})
   147  				if !ok {
   148  					require.NoError(t, fmt.Errorf("service '%s' is not a map[string]interface{}", serviceName))
   149  				}
   150  
   151  				// check for ports
   152  				ports, ok := params["ports"]
   153  				if !ok {
   154  					continue
   155  				}
   156  
   157  				portsSlice, ok := ports.([]interface{})
   158  				if !ok {
   159  					continue
   160  				}
   161  				for i := range portsSlice {
   162  					port, ok := portsSlice[i].(string)
   163  					if !ok {
   164  						continue
   165  					}
   166  
   167  					portSplitted := strings.Split(port, ":")
   168  					if len(portSplitted) < 2 {
   169  						continue
   170  					}
   171  
   172  					portsSlice[i] = portSplitted[len(portSplitted)-1]
   173  					changed = true
   174  				}
   175  			}
   176  		}
   177  
   178  	}
   179  	if !changed {
   180  		return e
   181  	}
   182  
   183  	path := filepath.Join(t.TempDir(), "docker-compose.yml")
   184  	data, err := yaml.Marshal(obj)
   185  	if err != nil {
   186  		require.NoError(t, err)
   187  	}
   188  
   189  	require.NoError(t, os.WriteFile(path, data, 0644))
   190  
   191  	return &env{
   192  		dir:  e.dir,
   193  		path: path,
   194  	}
   195  }
   196  
   197  // This test is meant to catch very fundamental errors in the examples. It could be extened to be more comprehensive. For now it will just run the examples and check that they don't crash, within 5 seconds.
   198  func TestDockerComposeBuildRun(t *testing.T) {
   199  	if testing.Short() {
   200  		t.Skip("skipping test in short mode.")
   201  	}
   202  
   203  	ctx := context.Background()
   204  
   205  	// find docker compose files
   206  	out, err := exec.Command("git", "ls-files", "**/docker-compose.yml").Output()
   207  	require.NoError(t, err)
   208  
   209  	var envs []*env
   210  	for _, path := range strings.Split(strings.TrimSpace(string(out)), "\n") {
   211  		e := &env{dir: filepath.Dir(path), path: path}
   212  		envs = append(envs, e)
   213  	}
   214  
   215  	for i := range envs {
   216  		t.Run(envs[i].dir, func(t *testing.T) {
   217  			e := envs[i]
   218  			t.Parallel()
   219  			ctx, cancel := context.WithTimeout(ctx, timeoutPerExample)
   220  			defer cancel()
   221  			t.Run("build", func(t *testing.T) {
   222  				cmd := e.newCmdWithOutputCapture(t, ctx, "build")
   223  				require.NoError(t, cmd.Run())
   224  			})
   225  			// run pull first so lcontainers can start immediately
   226  			t.Run("pull", func(t *testing.T) {
   227  				cmd := e.newCmdWithOutputCapture(t, ctx, "pull")
   228  				require.NoError(t, cmd.Run())
   229  			})
   230  			// now run the docker-compose containers, run them for 5 seconds, it would abort if one of the containers exits
   231  			t.Run("run", func(t *testing.T) {
   232  				ctx, cancel := context.WithCancel(ctx)
   233  				defer cancel()
   234  				e = e.removeExposedPorts(t)
   235  				cmd := e.newCmdWithOutputCapture(t, ctx, "up", "--abort-on-container-exit")
   236  				require.NoError(t, cmd.Start())
   237  
   238  				// cleanup what ever happens
   239  				defer func() {
   240  					err := e.newCmdWithOutputCapture(t, context.Background(), "down", "--volumes").Run()
   241  					if err != nil {
   242  						t.Logf("cleanup error=%v\n", err)
   243  					}
   244  				}()
   245  
   246  				// check if all containers are still running after 5 seconds
   247  				go func() {
   248  					<-time.After(durationToStayRunning)
   249  					err := e.containersAllRunning(ctx)
   250  					if err != nil {
   251  						t.Logf("do nothing, as not all containers are running: %v\n", err)
   252  						return
   253  					}
   254  					t.Log("all healthy, start graceful shutdown")
   255  					err = cmd.Process.Signal(syscall.SIGTERM)
   256  					if err != nil {
   257  						t.Log("error sending terminate signal", err)
   258  					}
   259  				}()
   260  
   261  				err := cmd.Wait()
   262  				var exitError *exec.ExitError
   263  				if !errors.As(err, &exitError) || exitError.ExitCode() != 130 {
   264  					require.NoError(t, err)
   265  				}
   266  
   267  			})
   268  		})
   269  	}
   270  
   271  }