github.com/quay/claircore@v1.5.28/test/integration/embedded.go (about)

     1  package integration
     2  
     3  import (
     4  	"archive/tar"
     5  	"archive/zip"
     6  	"encoding/xml"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"io/fs"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"path/filepath"
    15  	"regexp"
    16  	"runtime"
    17  	"strings"
    18  	"sync/atomic"
    19  	"testing"
    20  	"time"
    21  
    22  	"github.com/jackc/pgx/v4/pgxpool"
    23  	"github.com/ulikunitz/xz"
    24  
    25  	"github.com/quay/claircore/internal/xmlutil"
    26  )
    27  
    28  // MavenBOM is the bill-of-materials reported by maven.
    29  //
    30  //	<metadata>
    31  //	  <groupId>io.zonky.test.postgres</groupId>
    32  //	  <artifactId>embedded-postgres-binaries-bom</artifactId>
    33  //	  <versioning>
    34  //	    <latest>16.1.0</latest>
    35  //	    <release>16.1.0</release>
    36  //	    <versions>
    37  //	      <version>16.1.0</version>
    38  //	    </versions>
    39  //	    <lastUpdated>20231111034502</lastUpdated>
    40  //	  </versioning>
    41  //	</metadata>
    42  type mavenBOM struct {
    43  	GroupID    string `xml:"groupId"`
    44  	ArtifactID string `xml:"artifactId"`
    45  	Versioning struct {
    46  		Latest      string   `xml:"latest"`
    47  		Release     string   `xml:"release"`
    48  		LastUpdated int64    `xml:"lastUpdated"`
    49  		Versions    []string `xml:"versions>version"`
    50  	} `xml:"versioning"`
    51  }
    52  
    53  type fetchDescriptor struct {
    54  	OS          string
    55  	Arch        string
    56  	Version     string
    57  	RealVersion string
    58  	cached      atomic.Bool
    59  }
    60  
    61  var embedDB = fetchDescriptor{
    62  	OS:      runtime.GOOS,
    63  	Arch:    findArch(), // This is a per-OS function.
    64  	Version: `latest`,
    65  }
    66  
    67  func init() {
    68  	// See if a different version was requested.
    69  	if e := os.Getenv(EnvPGVersion); e != "" {
    70  		embedDB.Version = e
    71  	}
    72  }
    73  
    74  func startEmbedded(t testing.TB) func() {
    75  	if os.Getuid() == 0 {
    76  		// Print warning to prevent wary travelers needing to go spelunking in
    77  		// the logs.
    78  		t.Log("⚠️ PostgreSQL refuses to start as root; this will almost certainly not work ⚠️")
    79  	}
    80  	if embedDB.Arch == "" {
    81  		t.Logf(`⚠️ unsupported platform "%s/%s"; see https://mvnrepository.com/artifact/io.zonky.test.postgres/embedded-postgres-binaries-bom`,
    82  			runtime.GOOS, runtime.GOARCH,
    83  		)
    84  		t.Log("See the test/integration documentation for how to specify an external database.")
    85  		t.FailNow()
    86  	}
    87  	return func() {
    88  		pkgDB = &Engine{}
    89  		if err := pkgDB.Start(t); err != nil {
    90  			t.Log("unclean shutdown?", err)
    91  			if err := pkgDB.Stop(); err != nil {
    92  				t.Fatal(err)
    93  			}
    94  			if err := pkgDB.Start(t); err != nil {
    95  				t.Fatal(err)
    96  			}
    97  		}
    98  		cfg, err := pgxpool.ParseConfig(pkgDB.DSN)
    99  		if err != nil {
   100  			t.Error(err)
   101  			return
   102  		}
   103  		pkgConfig = cfg
   104  	}
   105  }
   106  
   107  func (a *fetchDescriptor) URL(t testing.TB) string {
   108  	const (
   109  		repo    = `https://repo1.maven.org`
   110  		pathFmt = `/maven2/io/zonky/test/postgres/embedded-postgres-binaries-%s-%s/%s/embedded-postgres-binaries-%[1]s-%s-%s.jar`
   111  	)
   112  	u, err := url.Parse(repo)
   113  	if err != nil {
   114  		t.Fatal(err)
   115  	}
   116  	u, err = u.Parse(fmt.Sprintf(pathFmt, a.OS, a.Arch, a.RealVersion))
   117  	if err != nil {
   118  		t.Fatal(err)
   119  	}
   120  	return u.String()
   121  }
   122  
   123  func (a *fetchDescriptor) Path(t testing.TB) string {
   124  	return filepath.Join(CacheDir(t), fmt.Sprintf("postgres-%s-%s-%s", a.OS, a.Arch, a.Version))
   125  }
   126  
   127  func (a *fetchDescriptor) Realpath(t testing.TB) string {
   128  	if a.RealVersion == "" {
   129  		panic("realpath called before real version determined")
   130  	}
   131  	return filepath.Join(CacheDir(t), fmt.Sprintf("postgres-%s-%s-%s", a.OS, a.Arch, a.RealVersion))
   132  }
   133  
   134  // The URL that contains the list of available versions.
   135  const bomURL = `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-bom/maven-metadata.xml`
   136  
   137  var versionRE = regexp.MustCompile(`^[0-9]+((\.[0-9]+){2})?$`)
   138  
   139  func (a *fetchDescriptor) DiscoverVersion(t testing.TB) {
   140  	if a.cached.Load() {
   141  		// Should be fine.
   142  		return
   143  	}
   144  	shouldFetch := false
   145  	skip := skip()
   146  	defer func() {
   147  		if t.Failed() || t.Skipped() {
   148  			a.cached.Store(false)
   149  			return
   150  		}
   151  		a.cached.Store(!shouldFetch)
   152  		if !shouldFetch {
   153  			// If it does exist, wait until we can grab a shared lock. If this blocks,
   154  			// it's because another process has the exclusive (write) lock. Any error
   155  			// during this process just fails the test.
   156  			lockDirShared(t, a.Realpath(t))
   157  		}
   158  		if a.Version != a.RealVersion {
   159  			t.Logf("pattern %q resolved to version: %q", a.Version, a.RealVersion)
   160  		}
   161  	}()
   162  	if testing.Short() {
   163  		t.Skip("asked for short tests")
   164  	}
   165  
   166  	// Check if the version we've got is a pattern or a specific version:
   167  	ms := versionRE.FindStringSubmatch(a.Version)
   168  	switch {
   169  	case a.Version == "latest":
   170  		// OK
   171  	case ms == nil:
   172  		// Invalid
   173  		t.Fatalf(`unknown version pattern %q; must be "\d+\.\d+\.\d+", "\d+", or "latest"`, a.Version)
   174  	case ms[1] != "":
   175  		// Full version
   176  		a.RealVersion = a.Version
   177  		_, err := os.Stat(a.Realpath(t))
   178  		missing := errors.Is(err, fs.ErrNotExist)
   179  		switch {
   180  		case !missing: // OK
   181  		case skip:
   182  			t.Skip("skipping integration test: would need to fetch binaries")
   183  		case !skip:
   184  			shouldFetch = true
   185  		}
   186  		return
   187  	default:
   188  		// Pattern
   189  	}
   190  
   191  	// Execution being here means "Version" is a pattern, so the path reported
   192  	// by [fetchDescriptor.Path] should be a symlink.
   193  
   194  	fi, linkErr := os.Lstat(a.Path(t))
   195  	_, dirErr := os.Stat(a.Path(t))
   196  	missing := errors.Is(linkErr, fs.ErrNotExist) || errors.Is(dirErr, fs.ErrNotExist)
   197  	fresh := false
   198  	if fi != nil {
   199  		if fi.Mode()&fs.ModeSymlink == 0 {
   200  			t.Fatalf("path %q is not a symlink", a.Path(t))
   201  		}
   202  		const week = 7 * 24 * time.Hour
   203  		fresh = fi.ModTime().After(time.Now().Add(-1 * week))
   204  	}
   205  
   206  	const week = 7 * 24 * time.Hour
   207  	var bom mavenBOM
   208  	var dec *xml.Decoder
   209  	switch {
   210  	case skip && missing:
   211  		t.Skip("skipping integration test: would need to fetch bom & binaries")
   212  	case !skip && !missing && fresh:
   213  		fallthrough
   214  	case skip && !missing:
   215  		if a.RealVersion != "" {
   216  			return
   217  		}
   218  		// If a symlink exists, read the pointed-to version and we're done.
   219  		dst, err := os.Readlink(a.Path(t))
   220  		if err != nil {
   221  			t.Fatal(err)
   222  		}
   223  		i := strings.LastIndexByte(dst, '-')
   224  		a.RealVersion = dst[i+1:]
   225  		return
   226  	case !skip && !missing && !fresh:
   227  		fallthrough
   228  	case !skip && missing:
   229  		res, err := http.Get(bomURL) // Use of http.DefaultClient guarded by integration.Skip call.
   230  		if err != nil {
   231  			t.Fatal(err)
   232  		}
   233  		defer res.Body.Close()
   234  		if res.StatusCode != http.StatusOK {
   235  			t.Fatalf("unexpected response: %v", res.Status)
   236  		}
   237  		dec = xml.NewDecoder(res.Body)
   238  	}
   239  
   240  	dec.CharsetReader = xmlutil.CharsetReader
   241  	if err := dec.Decode(&bom); err != nil {
   242  		t.Fatal(err)
   243  	}
   244  
   245  	if a.Version == "latest" {
   246  		a.RealVersion = bom.Versioning.Latest
   247  	} else {
   248  		prefix := a.Version + "."
   249  		vs := bom.Versioning.Versions
   250  		for i := len(vs) - 1; i >= 0; i-- {
   251  			v := vs[i]
   252  			if strings.HasPrefix(v, prefix) {
   253  				a.RealVersion = v
   254  				break
   255  			}
   256  		}
   257  	}
   258  	if a.RealVersion == "" {
   259  		t.Fatalf("unable to find a version for %q", a.Version)
   260  	}
   261  
   262  	_, linkErr = os.Stat(a.Realpath(t))
   263  	shouldFetch = errors.Is(linkErr, os.ErrNotExist)
   264  }
   265  
   266  func (a *fetchDescriptor) FetchArchive(t testing.TB) {
   267  	if a.cached.Load() {
   268  		return
   269  	}
   270  	p := a.Realpath(t)
   271  
   272  	if a.Version != a.RealVersion {
   273  		link := a.Path(t)
   274  		t.Logf("adding symlink %q → %q", link, p)
   275  		os.Remove(link)
   276  		if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
   277  			t.Error(err)
   278  		}
   279  		if err := os.Symlink(p, link); err != nil {
   280  			t.Fatal(err)
   281  		}
   282  		if err := os.MkdirAll(p, 0o755); err != nil {
   283  			t.Error(err)
   284  		}
   285  	}
   286  	if !lockDir(t, p) {
   287  		return
   288  	}
   289  
   290  	// Fetch and buffer the jar.
   291  	u := a.URL(t)
   292  	t.Logf("fetching %q", u)
   293  	res, err := http.Get(u) // Use of http.DefaultClient guarded by integration.Skip call.
   294  	if err != nil {
   295  		t.Fatal(err)
   296  	}
   297  	defer res.Body.Close()
   298  	if res.StatusCode != http.StatusOK {
   299  		t.Fatalf("unexpected response: %v", res.Status)
   300  	}
   301  	t.Log("fetch OK")
   302  	jf, err := os.CreateTemp(t.TempDir(), "embedded-postgres.")
   303  	if err != nil {
   304  		t.Fatal(err)
   305  	}
   306  	defer jf.Close()
   307  	sz, err := io.Copy(jf, res.Body)
   308  	if err != nil {
   309  		t.Fatal(err)
   310  	}
   311  	if _, err := jf.Seek(0, io.SeekStart); err != nil {
   312  		t.Fatal(err)
   313  	}
   314  
   315  	// Open the jar (note a jar is just a zip with specific contents) and find
   316  	// the tarball.
   317  	r, err := zip.NewReader(jf, sz)
   318  	if err != nil {
   319  		t.Fatal(err)
   320  	}
   321  	var zf *zip.File
   322  	for _, h := range r.File {
   323  		if !strings.HasSuffix(h.Name, ".txz") {
   324  			continue
   325  		}
   326  		zf = h
   327  		break
   328  	}
   329  	if zf == nil {
   330  		t.Fatal("didn't find txz")
   331  	}
   332  
   333  	// Extract the tarball to the target directory.
   334  	t.Logf("extracting %q to %q", zf.Name, p)
   335  	rd, err := zf.Open()
   336  	if err != nil {
   337  		t.Fatal(err)
   338  	}
   339  	defer rd.Close()
   340  	tf, err := xz.NewReader(rd)
   341  	if err != nil {
   342  		t.Fatal(err)
   343  	}
   344  	tr := tar.NewReader(tf)
   345  	var h *tar.Header
   346  	for h, err = tr.Next(); err == nil && !t.Failed(); h, err = tr.Next() {
   347  		outName := filepath.Join(p, normPath(h.Name))
   348  		// Experimentally, these are the types we need to support when
   349  		// extracting the tarballs.
   350  		//
   351  		// All the Mkdir calls are because tar, as a format, doesn't enforce
   352  		// ordering, e.g. an entry for `a/b/c` and then `a/` is valid.
   353  		//
   354  		// This also plays fast and loose with permissions around directories.
   355  		switch h.Typeflag {
   356  		case tar.TypeDir:
   357  			if err := os.MkdirAll(outName, 0o755); err != nil {
   358  				t.Error(err)
   359  			}
   360  			if err := os.Chmod(outName, h.FileInfo().Mode()); err != nil {
   361  				t.Error(err)
   362  			}
   363  		case tar.TypeReg:
   364  			if err := os.MkdirAll(filepath.Dir(outName), 0o755); err != nil {
   365  				t.Error(err)
   366  			}
   367  			f, err := os.Create(outName)
   368  			if err != nil {
   369  				t.Error(err)
   370  			}
   371  			// Don't defer the Close, make sure we're unconditionally closing
   372  			// the file on every loop.
   373  			if _, err := io.Copy(f, tr); err != nil {
   374  				t.Error(err)
   375  			}
   376  			if err := f.Chmod(h.FileInfo().Mode()); err != nil {
   377  				t.Error(err)
   378  			}
   379  			if err := f.Close(); err != nil {
   380  				t.Error(err)
   381  			}
   382  		case tar.TypeSymlink:
   383  			if err := os.MkdirAll(filepath.Dir(outName), 0o755); err != nil {
   384  				t.Error(err)
   385  			}
   386  			tgt := filepath.Join(filepath.Dir(outName), normPath(h.Linkname))
   387  			if err := os.Symlink(tgt, outName); err != nil {
   388  				t.Error(err)
   389  			}
   390  		}
   391  	}
   392  	if t.Failed() {
   393  		t.FailNow()
   394  	}
   395  	if err != io.EOF {
   396  		t.Fatal(err)
   397  	}
   398  	t.Log("extraction OK")
   399  }
   400  
   401  func normPath(p string) string {
   402  	return filepath.Join("/", p)[1:]
   403  }