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 }