github.com/filecoin-project/bacalhau@v0.3.23-0.20230228154132-45c989550ace/pkg/executor/docker/executor_test.go (about)

     1  //go:build unit || !integration
     2  
     3  package docker
     4  
     5  import (
     6  	"context"
     7  	"fmt"
     8  	"net"
     9  	"net/http"
    10  	"net/url"
    11  	"runtime"
    12  	"strconv"
    13  	"strings"
    14  	"testing"
    15  	"time"
    16  
    17  	"github.com/filecoin-project/bacalhau/pkg/compute/capacity"
    18  	"github.com/filecoin-project/bacalhau/pkg/docker"
    19  	"github.com/filecoin-project/bacalhau/pkg/model"
    20  	"github.com/filecoin-project/bacalhau/pkg/storage"
    21  	"github.com/filecoin-project/bacalhau/pkg/system"
    22  	"github.com/stretchr/testify/require"
    23  	"github.com/stretchr/testify/suite"
    24  )
    25  
    26  type ExecutorTestSuite struct {
    27  	suite.Suite
    28  	executor *Executor
    29  	server   *http.Server
    30  	cm       *system.CleanupManager
    31  }
    32  
    33  func TestExecutorTestSuite(t *testing.T) {
    34  	suite.Run(t, new(ExecutorTestSuite))
    35  }
    36  
    37  func (s *ExecutorTestSuite) SetupTest() {
    38  	docker.MustHaveDocker(s.T())
    39  
    40  	var err error
    41  	s.cm = system.NewCleanupManager()
    42  	s.T().Cleanup(func() {
    43  		s.cm.Cleanup(context.Background())
    44  	})
    45  
    46  	s.executor, err = NewExecutor(
    47  		context.Background(),
    48  		s.cm,
    49  		"bacalhau-executor-unittest",
    50  		model.NewMappedProvider(map[model.StorageSourceType]storage.Storage{}),
    51  	)
    52  	require.NoError(s.T(), err)
    53  
    54  	handler := func(w http.ResponseWriter, r *http.Request) {
    55  		w.Write([]byte(r.URL.Path))
    56  	}
    57  
    58  	// We have to manually discover the correct IP address for the server to
    59  	// listen on because on Linux hosts simply using 127.0.0.1 will get caught
    60  	// in the loopback interface of the gateway container. We have to listen on
    61  	// whatever "host.docker.internal" resolves to, which is the IP address of
    62  	// the "docker0" interface.
    63  	var gateway net.IP
    64  	if runtime.GOOS == "linux" {
    65  		gateway, err = s.executor.client.HostGatewayIP(context.Background())
    66  		require.NoError(s.T(), err)
    67  	} else {
    68  		gateway = net.ParseIP("127.0.0.1")
    69  	}
    70  
    71  	serverAddr := net.TCPAddr{IP: gateway, Port: 0}
    72  	listener, err := net.Listen("tcp", serverAddr.String())
    73  	require.NoError(s.T(), err)
    74  	// Don't need to close the listener as it'll be closed by the server.
    75  
    76  	s.server = &http.Server{
    77  		Addr:    listener.Addr().String(),
    78  		Handler: http.HandlerFunc(handler),
    79  	}
    80  	s.cm.RegisterCallback(s.server.Close)
    81  	go s.server.Serve(listener)
    82  }
    83  
    84  func (s *ExecutorTestSuite) containerHttpURL() *url.URL {
    85  	url, err := url.Parse("http://" + s.server.Addr)
    86  	require.NoError(s.T(), err)
    87  
    88  	// On Mac/Windows, we are within a VM and hence we need to route to the
    89  	// host. On Linux we are not, so localhost should work.
    90  	// See e.g. https://stackoverflow.com/a/24326540
    91  	url.Host = fmt.Sprintf("%s:%s", dockerHostHostname, url.Port())
    92  	return url
    93  }
    94  
    95  func (s *ExecutorTestSuite) curlTask() model.JobSpecDocker {
    96  	return model.JobSpecDocker{
    97  		Image:      "curlimages/curl",
    98  		Entrypoint: []string{"curl", "--fail-with-body", s.containerHttpURL().JoinPath("hello.txt").String()},
    99  	}
   100  }
   101  
   102  func (s *ExecutorTestSuite) runJob(spec model.Spec) (*model.RunCommandResult, error) {
   103  	return s.runJobWithContext(context.Background(), spec)
   104  }
   105  
   106  func (s *ExecutorTestSuite) runJobWithContext(ctx context.Context, spec model.Spec) (*model.RunCommandResult, error) {
   107  	result := s.T().TempDir()
   108  	j := &model.Job{Metadata: model.Metadata{ID: "test"}, Spec: spec}
   109  	shard := model.JobShard{Job: j, Index: 0}
   110  	return s.executor.RunShard(ctx, shard, result)
   111  }
   112  
   113  func (s *ExecutorTestSuite) runJobGetStdout(spec model.Spec) (string, error) {
   114  	runnerOutput, runErr := s.runJob(spec)
   115  	return runnerOutput.STDOUT, runErr
   116  }
   117  
   118  const (
   119  	CPU_LIMIT    = "100m"
   120  	MEMORY_LIMIT = "100mb"
   121  )
   122  
   123  func (s *ExecutorTestSuite) TestDockerResourceLimitsCPU() {
   124  	if runtime.GOOS == "windows" {
   125  		s.T().Skip("Resource limits don't apply to containers running on Windows")
   126  	}
   127  
   128  	// this will give us a numerator and denominator that should end up at the
   129  	// same 0.1 value that 100m means
   130  	// https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/managing_monitoring_and_updating_the_kernel/using-cgroups-v2-to-control-distribution-of-cpu-time-for-applications_managing-monitoring-and-updating-the-kernel#proc_controlling-distribution-of-cpu-time-for-applications-by-adjusting-cpu-bandwidth_using-cgroups-v2-to-control-distribution-of-cpu-time-for-applications
   131  	result, err := s.runJobGetStdout(model.Spec{
   132  		Engine: model.EngineDocker,
   133  		Resources: model.ResourceUsageConfig{
   134  			CPU:    CPU_LIMIT,
   135  			Memory: MEMORY_LIMIT,
   136  		},
   137  		Docker: model.JobSpecDocker{
   138  			Image:      "ubuntu",
   139  			Entrypoint: []string{"bash", "-c", "cat /sys/fs/cgroup/cpu.max"},
   140  		},
   141  	})
   142  
   143  	values := strings.Fields(result)
   144  
   145  	numerator, err := strconv.Atoi(values[0])
   146  	require.NoError(s.T(), err)
   147  
   148  	denominator, err := strconv.Atoi(values[1])
   149  	require.NoError(s.T(), err)
   150  
   151  	var containerCPU float64 = 0
   152  
   153  	if denominator > 0 {
   154  		containerCPU = float64(numerator) / float64(denominator)
   155  	}
   156  
   157  	require.Equal(s.T(), capacity.ConvertCPUString(CPU_LIMIT), containerCPU, "the container reported CPU does not equal the configured limit")
   158  }
   159  
   160  func (s *ExecutorTestSuite) TestDockerResourceLimitsMemory() {
   161  	if runtime.GOOS == "windows" {
   162  		s.T().Skip("Resource limits don't apply to containers running on Windows")
   163  	}
   164  
   165  	// this will give us a numerator and denominator that should end up at the
   166  	// same 0.1 value that 100m means
   167  	// https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/managing_monitoring_and_updating_the_kernel/using-cgroups-v2-to-control-distribution-of-cpu-time-for-applications_managing-monitoring-and-updating-the-kernel#proc_controlling-distribution-of-cpu-time-for-applications-by-adjusting-cpu-bandwidth_using-cgroups-v2-to-control-distribution-of-cpu-time-for-applications
   168  	result, err := s.runJobGetStdout(model.Spec{
   169  		Engine: model.EngineDocker,
   170  		Resources: model.ResourceUsageConfig{
   171  			CPU:    CPU_LIMIT,
   172  			Memory: MEMORY_LIMIT,
   173  		},
   174  		Docker: model.JobSpecDocker{
   175  			Image:      "ubuntu",
   176  			Entrypoint: []string{"bash", "-c", "cat /sys/fs/cgroup/memory.max"},
   177  		},
   178  	})
   179  	require.NoError(s.T(), err)
   180  
   181  	intVar, err := strconv.Atoi(strings.TrimSpace(result))
   182  	require.NoError(s.T(), err)
   183  	require.Equal(s.T(), capacity.ConvertBytesString(MEMORY_LIMIT), uint64(intVar), "the container reported memory does not equal the configured limit")
   184  }
   185  
   186  func (s *ExecutorTestSuite) TestDockerNetworkingFull() {
   187  	result, err := s.runJob(model.Spec{
   188  		Engine:  model.EngineDocker,
   189  		Network: model.NetworkConfig{Type: model.NetworkFull},
   190  		Docker:  s.curlTask(),
   191  	})
   192  	require.NoError(s.T(), err, result.STDERR)
   193  	require.Zero(s.T(), result.ExitCode, result.STDERR)
   194  	require.Equal(s.T(), "/hello.txt", result.STDOUT)
   195  }
   196  
   197  func (s *ExecutorTestSuite) TestDockerNetworkingNone() {
   198  	result, err := s.runJob(model.Spec{
   199  		Engine:  model.EngineDocker,
   200  		Network: model.NetworkConfig{Type: model.NetworkNone},
   201  		Docker:  s.curlTask(),
   202  	})
   203  	require.NoError(s.T(), err)
   204  	require.Empty(s.T(), result.STDOUT)
   205  	require.NotEmpty(s.T(), result.STDERR)
   206  	require.NotZero(s.T(), result.ExitCode)
   207  }
   208  
   209  func (s *ExecutorTestSuite) TestDockerNetworkingHTTP() {
   210  	result, err := s.runJob(model.Spec{
   211  		Engine: model.EngineDocker,
   212  		Network: model.NetworkConfig{
   213  			Type:    model.NetworkHTTP,
   214  			Domains: []string{s.containerHttpURL().Hostname()},
   215  		},
   216  		Docker: s.curlTask(),
   217  	})
   218  	require.NoError(s.T(), err, result.STDERR)
   219  	require.Zero(s.T(), result.ExitCode, result.STDERR)
   220  	require.Equal(s.T(), "/hello.txt", result.STDOUT)
   221  }
   222  
   223  func (s *ExecutorTestSuite) TestDockerNetworkingHTTPWithMultipleDomains() {
   224  	result, err := s.runJob(model.Spec{
   225  		Engine: model.EngineDocker,
   226  		Network: model.NetworkConfig{
   227  			Type: model.NetworkHTTP,
   228  			Domains: []string{
   229  				s.containerHttpURL().Hostname(),
   230  				"bacalhau.org",
   231  			},
   232  		},
   233  		Docker: s.curlTask(),
   234  	})
   235  	require.NoError(s.T(), err, result.STDERR)
   236  	require.Zero(s.T(), result.ExitCode, result.STDERR)
   237  	require.Equal(s.T(), "/hello.txt", result.STDOUT)
   238  }
   239  
   240  func (s *ExecutorTestSuite) TestDockerNetworkingWithSubdomains() {
   241  	hostname := s.containerHttpURL().Hostname()
   242  	hostroot := strings.Join(strings.SplitN(hostname, ".", 2)[:1], ".")
   243  
   244  	result, err := s.runJob(model.Spec{
   245  		Engine: model.EngineDocker,
   246  		Network: model.NetworkConfig{
   247  			Type:    model.NetworkHTTP,
   248  			Domains: []string{hostname, hostroot},
   249  		},
   250  		Docker: s.curlTask(),
   251  	})
   252  	require.NoError(s.T(), err, result.STDERR)
   253  	require.Zero(s.T(), result.ExitCode, result.STDERR)
   254  	require.Equal(s.T(), "/hello.txt", result.STDOUT)
   255  }
   256  
   257  func (s *ExecutorTestSuite) TestDockerNetworkingFiltersHTTP() {
   258  	result, err := s.runJob(model.Spec{
   259  		Engine: model.EngineDocker,
   260  		Network: model.NetworkConfig{
   261  			Type:    model.NetworkHTTP,
   262  			Domains: []string{"bacalhau.org"},
   263  		},
   264  		Docker: s.curlTask(),
   265  	})
   266  	// The curl will succeed but should return a non-zero exit code and error page.
   267  	require.NoError(s.T(), err)
   268  	require.NotZero(s.T(), result.ExitCode)
   269  	require.Contains(s.T(), result.STDOUT, "ERROR: The requested URL could not be retrieved")
   270  }
   271  
   272  func (s *ExecutorTestSuite) TestDockerNetworkingFiltersHTTPS() {
   273  	result, err := s.runJob(model.Spec{
   274  		Engine: model.EngineDocker,
   275  		Network: model.NetworkConfig{
   276  			Type:    model.NetworkHTTP,
   277  			Domains: []string{s.containerHttpURL().Hostname()},
   278  		},
   279  		Docker: model.JobSpecDocker{
   280  			Image:      "curlimages/curl",
   281  			Entrypoint: []string{"curl", "--fail-with-body", "https://www.bacalhau.org"},
   282  		},
   283  	})
   284  	// The curl will succeed but should return a non-zero exit code and error page.
   285  	require.NoError(s.T(), err)
   286  	require.NotZero(s.T(), result.ExitCode)
   287  	require.Empty(s.T(), result.STDOUT)
   288  }
   289  
   290  func (s *ExecutorTestSuite) TestDockerNetworkingAppendsHTTPHeader() {
   291  	s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   292  		_, err := w.Write([]byte(r.Header.Get("X-Bacalhau-Job-ID")))
   293  		s.Require().NoError(err)
   294  	})
   295  	result, err := s.runJob(model.Spec{
   296  		Engine: model.EngineDocker,
   297  		Network: model.NetworkConfig{
   298  			Type:    model.NetworkHTTP,
   299  			Domains: []string{s.containerHttpURL().Hostname()},
   300  		},
   301  		Docker: s.curlTask(),
   302  	})
   303  	require.NoError(s.T(), err)
   304  	require.Equal(s.T(), "test", result.STDOUT, result.STDOUT)
   305  }
   306  
   307  func (s *ExecutorTestSuite) TestTimesOutCorrectly() {
   308  	expected := "message after sleep"
   309  	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
   310  	defer cancel()
   311  
   312  	result, err := s.runJobWithContext(ctx, model.Spec{
   313  		Engine: model.EngineDocker,
   314  		Docker: model.JobSpecDocker{
   315  			Image:      "ubuntu",
   316  			Entrypoint: []string{"bash", "-c", fmt.Sprintf(`sleep 1 && echo "%s" && sleep 20`, expected)},
   317  		},
   318  	})
   319  	s.ErrorIs(err, context.DeadlineExceeded)
   320  	s.Truef(strings.HasPrefix(result.STDOUT, expected), "'%s' does not start with '%s'", result.STDOUT, expected)
   321  }