github.com/drud/ddev@v1.21.5-alpha1.0.20230226034409-94fcc4b94453/pkg/testcommon/testcommon.go (about) 1 package testcommon 2 3 import ( 4 "crypto/tls" 5 "github.com/docker/docker/pkg/homedir" 6 "github.com/drud/ddev/pkg/ddevapp" 7 "github.com/drud/ddev/pkg/globalconfig" 8 "github.com/drud/ddev/pkg/output" 9 "io" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "runtime" 14 "time" 15 16 log "github.com/sirupsen/logrus" 17 18 "path" 19 20 "fmt" 21 22 "github.com/drud/ddev/pkg/archive" 23 "github.com/drud/ddev/pkg/dockerutil" 24 "github.com/drud/ddev/pkg/fileutil" 25 "github.com/drud/ddev/pkg/util" 26 "github.com/pkg/errors" 27 asrt "github.com/stretchr/testify/assert" 28 "net/http" 29 "net/url" 30 "testing" 31 ) 32 33 // URIWithExpect pairs a URI like "/readme.html" with some substring content "should be found in URI" 34 type URIWithExpect struct { 35 URI string 36 Expect string 37 } 38 39 // TestSite describes a site for testing, with name, URL of tarball, and optional dir. 40 type TestSite struct { 41 // Name is the generic name of the site, and is used as the default dir. 42 Name string 43 // SourceURL is the URL of the source code tarball to be used for building the site. 44 SourceURL string 45 // ArchiveExtractionPath is the relative path within the tarball which should be extracted, ending with / 46 ArchiveInternalExtractionPath string 47 // FullSiteTarballURL is the URL of the tarball of a full site archive used for testing import. 48 FullSiteTarballURL string 49 // FilesTarballURL is the URL of the tarball of file uploads used for testing file import. 50 FilesTarballURL string 51 // FilesZipballURL is the URL of the zipball of file uploads used for testing file import. 52 FilesZipballURL string 53 // DBTarURL is the URL of the database dump tarball used for testing database import. 54 DBTarURL string 55 // DBZipURL is the URL of an optional zip-style db dump. 56 DBZipURL string 57 // Dir is the rooted full path of the test site 58 Dir string 59 // HTTPProbeURI is the URI that can be probed to look for a working web container 60 HTTPProbeURI string 61 // Docroot is the subdirectory within the site that is the root/index.php 62 Docroot string 63 // Type is the type of application. This can be specified when a config file is not present 64 // for a test site. 65 Type string 66 // Safe200URIWithExpectation provides a static URI with contents that it can be expected to contain. 67 Safe200URIWithExpectation URIWithExpect 68 // DynamicURI provides a dynamic (after db load) URI with contents we can expect. 69 DynamicURI URIWithExpect 70 // UploadDir overrides the dir used for upload_dir 71 UploadDir string 72 // FilesImageURI is URI to a file loaded by import-files that is a jpg. 73 FilesImageURI string 74 // FullSiteArchiveExtPath is the path that should be extracted from inside an archive when 75 // importing the files from a full site archive 76 FullSiteArchiveExtPath string 77 } 78 79 // Prepare downloads and extracts a site codebase to a temporary directory. 80 func (site *TestSite) Prepare() error { 81 testDir := CreateTmpDir(site.Name) 82 site.Dir = testDir 83 84 err := os.Setenv("DDEV_NONINTERACTIVE", "true") 85 util.CheckErr(err) 86 87 cachedSrcDir, _, err := GetCachedArchive(site.Name, site.Name+"_siteArchive", site.ArchiveInternalExtractionPath, site.SourceURL) 88 89 if err != nil { 90 site.Cleanup() 91 return fmt.Errorf("Failed to GetCachedArchive, err=%v", err) 92 } 93 // We must copy into a directory that does not yet exist :( 94 err = os.Remove(site.Dir) 95 util.CheckErr(err) 96 97 output.UserOut.Printf("Copying directory %s to %s\n", cachedSrcDir, site.Dir) 98 if runtime.GOOS != "windows" { 99 // Simple cp -r is far, far faster than our fileutil.CopyDir 100 cmd := exec.Command("bash", "-c", fmt.Sprintf(`cp -rp %s %s`, cachedSrcDir, site.Dir)) 101 err = cmd.Run() 102 } else { 103 err = fileutil.CopyDir(cachedSrcDir, site.Dir) 104 } 105 if err != nil { 106 site.Cleanup() 107 return fmt.Errorf("Failed to CopyDir from %s to %s, err=%v", cachedSrcDir, site.Dir, err) 108 } 109 output.UserOut.Println("Copying complete") 110 111 // Create an app. Err is ignored as we may not have 112 // a config file to read in from a test site. 113 app, err := ddevapp.NewApp(site.Dir, true) 114 if err != nil { 115 return err 116 } 117 // Set app name to the name we define for test sites. We'll 118 // ignore app name defined in config file if present. 119 app.Name = site.Name 120 app.Docroot = site.Docroot 121 app.UploadDir = site.UploadDir 122 app.Type = app.DetectAppType() 123 if app.Type != site.Type { 124 return errors.Errorf("Detected apptype (%s) does not match provided apptype (%s)", app.Type, site.Type) 125 } 126 127 err = app.ConfigFileOverrideAction() 128 util.CheckErr(err) 129 130 err = os.MkdirAll(filepath.Join(app.AppRoot, app.Docroot, app.GetUploadDir()), 0777) 131 if err != nil { 132 return fmt.Errorf("Failed to create upload dir for test site: %v", err) 133 } 134 135 err = app.WriteConfig() 136 if err != nil { 137 return errors.Errorf("Failed to write site config for site %s, dir %s, err: %v", app.Name, app.GetAppRoot(), err) 138 } 139 140 return nil 141 } 142 143 // Chdir will change to the directory for the site specified by TestSite. 144 func (site *TestSite) Chdir() func() { 145 return Chdir(site.Dir) 146 } 147 148 // Cleanup removes the archive and codebase extraction for a site after a test run has completed. 149 func (site *TestSite) Cleanup() { 150 // CleanupDir checks its own errors. 151 CleanupDir(site.Dir) 152 153 _ = globalconfig.RemoveProjectInfo(site.Name) 154 siteData := filepath.Join(globalconfig.GetGlobalDdevDir(), site.Name) 155 if fileutil.FileExists(siteData) { 156 CleanupDir(siteData) 157 } 158 } 159 160 // CleanupDir removes a directory specified by string. 161 func CleanupDir(dir string) { 162 err := os.RemoveAll(dir) 163 if err != nil { 164 log.Warn(fmt.Sprintf("Failed to remove directory %s, err: %v", dir, err)) 165 } 166 } 167 168 // OsTempDir gets os.TempDir() (usually provided by $TMPDIR) but expands any symlinks found within it. 169 // This wrapper function can prevent problems with docker-for-mac trying to use /var/..., which is not typically 170 // shared/mounted. It will be expanded via the /var symlink to /private/var/... 171 func OsTempDir() (string, error) { 172 dirName := os.TempDir() 173 tmpDir, err := filepath.EvalSymlinks(dirName) 174 if err != nil { 175 return "", err 176 } 177 tmpDir = filepath.Clean(tmpDir) 178 return tmpDir, nil 179 } 180 181 // CreateTmpDir creates a temporary directory and returns its path as a string. 182 func CreateTmpDir(prefix string) string { 183 baseTmpDir := filepath.Join(homedir.Get(), "tmp", "ddevtest") 184 _ = os.MkdirAll(baseTmpDir, 0755) 185 fullPath, err := os.MkdirTemp(baseTmpDir, prefix) 186 if err != nil { 187 log.Fatalf("Failed to create temp directory %s, err=%v", fullPath, err) 188 } 189 // Make the tmpdir fully writeable/readable, NFS problems 190 _ = os.Chmod(fullPath, 0777) 191 return fullPath 192 } 193 194 // Chdir will change to the directory for the site specified by TestSite. 195 // It returns an anonymous function which will return to the original working directory when called. 196 func Chdir(path string) func() { 197 curDir, _ := os.Getwd() 198 err := os.Chdir(path) 199 if err != nil { 200 log.Errorf("Could not change to directory %s: %v\n", path, err) 201 } 202 203 return func() { 204 err := os.Chdir(curDir) 205 if err != nil { 206 log.Errorf("Failed to change directory to original dir=%s, err=%v", curDir, err) 207 } 208 } 209 } 210 211 // ClearDockerEnv unsets env vars set in platform DockerEnv() so that 212 // they can be set by another test run. 213 func ClearDockerEnv() { 214 envVars := []string{ 215 "COMPOSE_PROJECT_NAME", 216 "COMPOSE_CONVERT_WINDOWS_PATHS", 217 "DDEV_SITENAME", 218 "DDEV_DBIMAGE", 219 "DDEV_WEBIMAGE", 220 "DDEV_APPROOT", 221 "DDEV_HOST_WEBSERVER_PORT", 222 "DDEV_HOST_HTTPS_PORT", 223 "DDEV_DOCROOT", 224 "DDEV_HOSTNAME", 225 "DDEV_DB_CONTAINER_COMMAND", 226 "DDEV_PHP_VERSION", 227 "DDEV_WEBSERVER_TYPE", 228 "DDEV_PROJECT_TYPE", 229 "DDEV_ROUTER_HTTP_PORT", 230 "DDEV_ROUTER_HTTPS_PORT", 231 "DDEV_HOST_DB_PORT", 232 "DDEV_HOST_WEBSERVER_PORT", 233 "DDEV_PHPMYADMIN_PORT", 234 "DDEV_PHPMYADMIN_HTTPS_PORT", 235 "DDEV_MAILHOG_PORT", 236 "COLUMNS", 237 "LINES", 238 "DDEV_XDEBUG_ENABLED", 239 "IS_DDEV_PROJECT", 240 } 241 for _, env := range envVars { 242 err := os.Unsetenv(env) 243 if err != nil { 244 log.Printf("failed to unset %s: %v\n", env, err) 245 } 246 } 247 } 248 249 // ContainerCheck determines if a given container name exists and matches a given state 250 func ContainerCheck(checkName string, checkState string) (bool, error) { 251 // ensure we have docker network 252 client := dockerutil.GetDockerClient() 253 err := dockerutil.EnsureNetwork(client, dockerutil.NetName) 254 if err != nil { 255 log.Fatal(err) 256 } 257 258 containers, err := dockerutil.GetDockerContainers(true) 259 if err != nil { 260 log.Fatal(err) 261 } 262 263 for _, container := range containers { 264 name := dockerutil.ContainerName(container) 265 if name == checkName { 266 if container.State == checkState { 267 return true, nil 268 } 269 return false, errors.New("container " + name + " returned " + container.State) 270 } 271 } 272 273 return false, errors.New("unable to find container " + checkName) 274 } 275 276 // GetCachedArchive returns a directory populated with the contents of the specified archive, either from cache or 277 // from downloading and creating cache. 278 // siteName is the site.Name used for storage 279 // prefixString is the prefix used to disambiguate downloads and extracts 280 // internalExtractionPath is the place in the archive to start extracting 281 // sourceURL is the actual URL to download. 282 // Returns the extracted path, the tarball path (both possibly cached), and an error value. 283 func GetCachedArchive(siteName string, prefixString string, internalExtractionPath string, sourceURL string) (string, string, error) { 284 uniqueName := prefixString + "_" + path.Base(sourceURL) 285 testCache := filepath.Join(globalconfig.GetGlobalDdevDir(), "testcache", siteName) 286 archiveFullPath := filepath.Join(testCache, "tarballs", uniqueName) 287 _ = os.MkdirAll(filepath.Dir(archiveFullPath), 0777) 288 extractPath := filepath.Join(testCache, prefixString) 289 290 // Check to see if we have it cached, if so just return it. 291 dStat, dErr := os.Stat(extractPath) 292 aStat, aErr := os.Stat(archiveFullPath) 293 if dErr == nil && dStat.IsDir() && aErr == nil && !aStat.IsDir() { 294 return extractPath, archiveFullPath, nil 295 } 296 297 output.UserOut.Printf("Downloading %s", archiveFullPath) 298 _ = os.MkdirAll(extractPath, 0777) 299 err := util.DownloadFile(archiveFullPath, sourceURL, false) 300 if err != nil { 301 return extractPath, archiveFullPath, fmt.Errorf("Failed to download url=%s into %s, err=%v", sourceURL, archiveFullPath, err) 302 } 303 304 output.UserOut.Printf("Downloaded %s into %s", sourceURL, archiveFullPath) 305 306 err = os.RemoveAll(extractPath) 307 if err != nil { 308 return extractPath, "", fmt.Errorf("failed to remove %s: %v", extractPath, err) 309 } 310 if filepath.Ext(archiveFullPath) == ".zip" { 311 err = archive.Unzip(archiveFullPath, extractPath, internalExtractionPath) 312 } else { 313 err = archive.Untar(archiveFullPath, extractPath, internalExtractionPath) 314 } 315 if err != nil { 316 _ = fileutil.PurgeDirectory(extractPath) 317 _ = os.RemoveAll(extractPath) 318 _ = os.RemoveAll(archiveFullPath) 319 return extractPath, archiveFullPath, fmt.Errorf("archive extraction of %s failed err=%v", archiveFullPath, err) 320 } 321 return extractPath, archiveFullPath, nil 322 } 323 324 // GetLocalHTTPResponse takes a URL and optional timeout in seconds, 325 // hits the local docker for it, returns result 326 // Returns error (with the body) if not 200 status code. 327 func GetLocalHTTPResponse(t *testing.T, rawurl string, timeoutSecsAry ...int) (string, *http.Response, error) { 328 var timeoutSecs = 60 329 if len(timeoutSecsAry) > 0 { 330 timeoutSecs = timeoutSecsAry[0] 331 } 332 timeoutTime := time.Duration(timeoutSecs) * time.Second 333 assert := asrt.New(t) 334 335 u, err := url.Parse(rawurl) 336 if err != nil { 337 t.Fatalf("Failed to parse url %s: %v", rawurl, err) 338 } 339 port := u.Port() 340 341 dockerIP, err := dockerutil.GetDockerIP() 342 assert.NoError(err) 343 344 fakeHost := u.Hostname() 345 // Add the port if there is one. 346 u.Host = dockerIP 347 if port != "" { 348 u.Host = u.Host + ":" + port 349 } 350 localAddress := u.String() 351 352 // use ServerName: fakeHost to verify basic usage of certificate. 353 // This technique is from https://stackoverflow.com/a/47169975/215713 354 transport := &http.Transport{ 355 TLSClientConfig: &tls.Config{ServerName: fakeHost}, 356 } 357 358 // Do not follow redirects, https://stackoverflow.com/a/38150816/215713 359 client := &http.Client{ 360 CheckRedirect: func(req *http.Request, via []*http.Request) error { 361 return http.ErrUseLastResponse 362 }, 363 Transport: transport, 364 Timeout: timeoutTime, 365 } 366 367 req, err := http.NewRequest("GET", localAddress, nil) 368 369 if err != nil { 370 return "", nil, fmt.Errorf("Failed to NewRequest GET %s: %v", localAddress, err) 371 } 372 req.Host = fakeHost 373 374 resp, err := client.Do(req) 375 if err != nil { 376 return "", resp, err 377 } 378 379 //nolint: errcheck 380 defer resp.Body.Close() 381 bodyBytes, err := io.ReadAll(resp.Body) 382 if err != nil { 383 return "", resp, fmt.Errorf("unable to ReadAll resp.body: %v", err) 384 } 385 bodyString := string(bodyBytes) 386 if resp.StatusCode != 200 { 387 return bodyString, resp, fmt.Errorf("http status code was %d, not 200", resp.StatusCode) 388 } 389 return bodyString, resp, nil 390 } 391 392 // EnsureLocalHTTPContent will verify a URL responds with a 200 and expected content string 393 func EnsureLocalHTTPContent(t *testing.T, rawurl string, expectedContent string, timeoutSeconds ...int) (*http.Response, error) { 394 var httpTimeout = 40 395 if len(timeoutSeconds) > 0 { 396 httpTimeout = timeoutSeconds[0] 397 } 398 assert := asrt.New(t) 399 400 body, resp, err := GetLocalHTTPResponse(t, rawurl, httpTimeout) 401 // We see intermittent php-fpm SIGBUS failures, only on macOS. 402 // That results in a 502/503. If we get a 502/503 on macOS, try again. 403 // It seems to be a 502 with nginx-fpm and a 503 with apache-fpm 404 if runtime.GOOS == "darwin" && resp != nil && (resp.StatusCode >= 500) { 405 t.Logf("Received %d error on macOS, retrying GetLocalHTTPResponse", resp.StatusCode) 406 time.Sleep(time.Second) 407 body, resp, err = GetLocalHTTPResponse(t, rawurl, httpTimeout) 408 } 409 assert.NoError(err, "GetLocalHTTPResponse returned err on rawurl %s, resp=%v, body=%v: %v", rawurl, resp, body, err) 410 assert.Contains(body, expectedContent, "request %s got resp=%v, body:\n========\n%s\n==========\n", rawurl, resp, body) 411 return resp, err 412 } 413 414 // PortPair is for tests to use naming portsets for tests 415 type PortPair struct { 416 HTTPPort string 417 HTTPSPort string 418 }