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  }