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 }