diff --git a/images/compatibility/gitea/gitea/Dockerfile b/images/compatibility/gitea/gitea/Dockerfile new file mode 100644 index 0000000000..085065f4e3 --- /dev/null +++ b/images/compatibility/gitea/gitea/Dockerfile @@ -0,0 +1 @@ +FROM gitea/gitea:1.26.2 diff --git a/images/compatibility/gitea/postgres/Dockerfile b/images/compatibility/gitea/postgres/Dockerfile new file mode 100644 index 0000000000..30c6d90cf5 --- /dev/null +++ b/images/compatibility/gitea/postgres/Dockerfile @@ -0,0 +1 @@ +FROM postgres:16.4 diff --git a/images/compatibility/nextcloud/mariadb/Dockerfile b/images/compatibility/nextcloud/mariadb/Dockerfile new file mode 100644 index 0000000000..470469fd14 --- /dev/null +++ b/images/compatibility/nextcloud/mariadb/Dockerfile @@ -0,0 +1 @@ +FROM mariadb:12.3.2 diff --git a/images/compatibility/nextcloud/nextcloud/Dockerfile b/images/compatibility/nextcloud/nextcloud/Dockerfile new file mode 100644 index 0000000000..8a3292a604 --- /dev/null +++ b/images/compatibility/nextcloud/nextcloud/Dockerfile @@ -0,0 +1 @@ +FROM nextcloud:34.0.0 diff --git a/images/compatibility/wordpress/mysql/Dockerfile b/images/compatibility/wordpress/mysql/Dockerfile new file mode 100644 index 0000000000..610029e48e --- /dev/null +++ b/images/compatibility/wordpress/mysql/Dockerfile @@ -0,0 +1 @@ +FROM mysql:9.7.1 diff --git a/images/compatibility/wordpress/wordpress/Dockerfile b/images/compatibility/wordpress/wordpress/Dockerfile new file mode 100644 index 0000000000..b3b9964c80 --- /dev/null +++ b/images/compatibility/wordpress/wordpress/Dockerfile @@ -0,0 +1 @@ +FROM wordpress:7.0.0-php8.4-apache diff --git a/test/compatibility/BUILD b/test/compatibility/BUILD new file mode 100644 index 0000000000..5a66eced44 --- /dev/null +++ b/test/compatibility/BUILD @@ -0,0 +1,41 @@ +load("//tools:defs.bzl", "go_library") +load(":defs.bzl", "compatibility_test") + +package( + default_applicable_licenses = ["//:license"], + default_visibility = ["//:sandbox"], + licenses = ["notice"], +) + +go_library( + name = "compatibility", + testonly = True, + srcs = ["compatibility.go"], +) + +compatibility_test( + name = "gitea", + srcs = ["gitea/gitea_test.go"], + deps = [ + "//pkg/test/dockerutil", + "//test/compatibility", + ], +) + +compatibility_test( + name = "nextcloud", + srcs = ["nextcloud/nextcloud_test.go"], + deps = [ + "//pkg/test/dockerutil", + "//test/compatibility", + ], +) + +compatibility_test( + name = "wordpress", + srcs = ["wordpress/wordpress_test.go"], + deps = [ + "//pkg/test/dockerutil", + "//test/compatibility", + ], +) diff --git a/test/compatibility/README.md b/test/compatibility/README.md new file mode 100644 index 0000000000..96a15a4748 --- /dev/null +++ b/test/compatibility/README.md @@ -0,0 +1,33 @@ +# Application Compatibility Tests + +This directory contains compatibility tests used to automate the process of +determining third-party application compatibility with gVisor over time. + +To reduce iteration time, these tests are *not* a part of our CI/CD pipeline. + +## Application versions + +The images used for each stack are in `images/compatibility/`. +The application version under test is determined by the Dockerfile present in +these directories. + +## Running + +First load the required images (built from `images/compatibility/...`): + +``` +make load-compatibility_gitea +# or load every image at once. this can take a while: +make load-compatibility +``` + +Then run the native baseline and the gVisor target. The Docker daemon must be +running locally, and gVisor must be present as the `runsc` runtime: + +``` +make test TARGETS="//test/compatibility:gitea_native //test/compatibility:gitea_runsc" +``` + +## Website + +Soon the results from these tests will be listed on [gvisor.dev](https://gvisor.dev). \ No newline at end of file diff --git a/test/compatibility/compatibility.go b/test/compatibility/compatibility.go new file mode 100644 index 0000000000..0aa3c3c66d --- /dev/null +++ b/test/compatibility/compatibility.go @@ -0,0 +1,156 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package compatibility provides shared helpers for gVisor application +// compatibility tests. +package compatibility + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// WriteConfigFile writes content to a file named name in a fresh temporary +// directory and returns the file's absolute path. +func WriteConfigFile(t *testing.T, name, content string) string { + t.Helper() + dir, err := os.MkdirTemp("", "compat-config") + if err != nil { + t.Fatalf("failed to create temp config dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config file %s: %v", name, err) + } + return p +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +// Poll repeatedly calls cond until it returns nil, failing the test (via +// t.Fatalf) if it has not succeeded within timeout. It waits interval between +// attempts. +// +// This is the preferred way to wait for a stack to become ready: poll for a +// readiness signal (a health endpoint, an accepted connection, etc). +// +// desc is a short human-readable description of what is being waited for, used in +// the timeout message (e.g. "gitea API to be ready"). +func Poll(ctx context.Context, t *testing.T, desc string, timeout, interval time.Duration, cond func() error) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for { + if lastErr = cond(); lastErr == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %v waiting for %s: %v", timeout, desc, lastErr) + } + select { + case <-ctx.Done(): + t.Fatalf("context cancelled while waiting for %s: %v (last error: %v)", desc, ctx.Err(), lastErr) + case <-time.After(interval): + } + } +} + +// Request describes an HTTP request issued by a compatibility test. +type Request struct { + Method string // defaults to GET. + URL string + Body string + ContentType string + Host string // overrides the Host header if non-empty. + Username string // enables HTTP basic auth if non-empty. + Password string + Headers map[string]string + // Timeout overrides the default client timeout for this request. + // Zero uses the default from httpClient. + Timeout time.Duration +} + +func (r Request) method() string { + if r.Method == "" { + return http.MethodGet + } + return r.Method +} + +// Do issues the request and returns the response status code and body. A +// transport-level failure is returned as an error; an HTTP error status is +// not an error. +func (r Request) Do() (int, string, error) { + var body io.Reader + if r.Body != "" { + body = strings.NewReader(r.Body) + } + req, err := http.NewRequest(r.method(), r.URL, body) + if err != nil { + return 0, "", err + } + if r.ContentType != "" { + req.Header.Set("Content-Type", r.ContentType) + } + for k, v := range r.Headers { + req.Header.Set(k, v) + } + if r.Host != "" { + req.Host = r.Host + } + if r.Username != "" { + req.SetBasicAuth(r.Username, r.Password) + } + client := httpClient + if r.Timeout > 0 { + client = &http.Client{Timeout: r.Timeout} + } + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, "", fmt.Errorf("reading body of %s %s: %w", r.method(), r.URL, err) + } + return resp.StatusCode, string(respBody), nil +} + +// DoOrFatal issues the request, failing the test on a transport error or if the +// status code is not wantStatus, and returns the response body. +func (r Request) DoOrFatal(t *testing.T, wantStatus int) string { + t.Helper() + status, body, err := r.Do() + if err != nil { + t.Fatalf("%s %s failed: %v", r.method(), r.URL, err) + } + if status != wantStatus { + t.Fatalf("%s %s: got status %d, want %d; body: %s", r.method(), r.URL, status, wantStatus, body) + } + return body +} + +// Get is a convenience wrapper for a plain GET. +func Get(url string) (int, string, error) { + return Request{URL: url}.Do() +} diff --git a/test/compatibility/defs.bzl b/test/compatibility/defs.bzl new file mode 100644 index 0000000000..bfdd70ad6e --- /dev/null +++ b/test/compatibility/defs.bzl @@ -0,0 +1,46 @@ +"""Defines the compatibility_test macro for application compatibility tests.""" + +load("//tools:defs.bzl", "go_test") + +# Runtimes under which compatibility tests are run, as (target suffix, runtime) +# pairs. "native" is the unsandboxed baseline (runc), matching the naming used by +# the syscall tests; "runsc" is gVisor. +_RUNTIMES = [ + ("native", "runc"), + ("runsc", "runsc"), +] + +def compatibility_test(name, srcs, deps = [], data = [], tags = [], size = "large", **kwargs): + """compatibility_test generates one go_test target per runtime. + + For it generates: + _native: runs the test under runc (as a baseline). + _runsc: runs the test under runsc. + + Both targets share the same sources and differ only in the "--runtime" + argument passed to the test binary (which dockerutil.MakeContainer reads). + + Args: + name: base name; per-runtime targets append "_native"/"_runsc". + srcs: test sources. + deps: test dependencies. + data: runtime data dependencies. + tags: extra tags (in addition to "local" and "manual"). + size: test size (default "large"). + **kwargs: forwarded to go_test (e.g. visibility). + """ + for suffix, runtime in _RUNTIMES: + target_data = list(data) + if runtime != "runc": + # runsc is needed to invalidate the bazel cache on any code change. + target_data = target_data + ["//runsc"] + go_test( + name = name + "_" + suffix, + srcs = srcs, + size = size, + args = ["--runtime=" + runtime], + data = target_data, + tags = tags + ["local", "manual"], + deps = deps, + **kwargs + ) diff --git a/test/compatibility/gitea/gitea_test.go b/test/compatibility/gitea/gitea_test.go new file mode 100644 index 0000000000..7a1b8aeb08 --- /dev/null +++ b/test/compatibility/gitea/gitea_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gitea is a gVisor compatibility test for Gitea backed by PostgreSQL. +// +// The Gitea version under test is specified in images/compatibility/gitea/Dockerfile. +package gitea + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "gvisor.dev/gvisor/pkg/test/dockerutil" + "gvisor.dev/gvisor/test/compatibility" +) + +const ( + giteaImage = "compatibility/gitea/gitea" + postgresImage = "compatibility/gitea/postgres" + + dbName = "gitea" + dbUser = "gitea" + dbPassword = "giteapass" + + adminUser = "gvisoradmin" + adminPassword = "gvisor-Test-1234" + adminEmail = "admin@example.com" + + giteaPort = 3000 + + readyTimeout = 3 * time.Minute + pollInterval = 2 * time.Second +) + +func TestGitea(t *testing.T) { + ctx := context.Background() + + // PostgreSQL backend. + db := dockerutil.MakeContainer(ctx, t) + defer db.CleanUp(ctx) + if err := db.Spawn(ctx, dockerutil.RunOpts{ + Image: postgresImage, + Env: []string{ + "POSTGRES_DB=" + dbName, + "POSTGRES_USER=" + dbUser, + "POSTGRES_PASSWORD=" + dbPassword, + }, + }); err != nil { + t.Fatalf("failed to start postgres: %v", err) + } + + // Wait until Postgres accepts connections before starting Gitea. + compatibility.Poll(ctx, t, "postgres to accept connections", readyTimeout, pollInterval, func() error { + out, err := db.Exec(ctx, dockerutil.ExecOpts{}, "pg_isready", "-U", dbUser, "-d", dbName) + if err != nil { + return fmt.Errorf("pg_isready: %v (%s)", err, out) + } + return nil + }) + + // Gitea app container. + gitea := dockerutil.MakeContainer(ctx, t) + defer gitea.CleanUp(ctx) + if err := gitea.Spawn(ctx, dockerutil.RunOpts{ + Image: giteaImage, + Links: []string{db.MakeLink("db")}, + Env: []string{ + "GITEA__database__DB_TYPE=postgres", + "GITEA__database__HOST=db:5432", + "GITEA__database__NAME=" + dbName, + "GITEA__database__USER=" + dbUser, + "GITEA__database__PASSWD=" + dbPassword, + // Skip the interactive web install wizard; configure via env instead. + "GITEA__security__INSTALL_LOCK=true", + }, + }); err != nil { + t.Fatalf("failed to start gitea: %v", err) + } + + ip, err := gitea.FindIP(ctx, false) + if err != nil { + t.Fatalf("failed to find gitea IP: %v", err) + } + base := fmt.Sprintf("http://%s:%d", ip.String(), giteaPort) + + // Wait for Gitea's API to be ready. + compatibility.Poll(ctx, t, "gitea API to be ready", readyTimeout, pollInterval, func() error { + status, _, err := compatibility.Get(base + "/api/v1/version") + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("GET /api/v1/version: status %d", status) + } + return nil + }) + + // Log the running version. + if _, body, err := compatibility.Get(base + "/api/v1/version"); err == nil { + t.Logf("gitea version: %s", strings.TrimSpace(body)) + } + + // Create an admin user via the Gitea CLI. + if out, err := gitea.Exec(ctx, dockerutil.ExecOpts{User: "git"}, + "gitea", "admin", "user", "create", + "--admin", + "--username", adminUser, + "--password", adminPassword, + "--email", adminEmail, + "--must-change-password=false", + ); err != nil { + t.Fatalf("failed to create admin user: %v\n%s", err, out) + } + + // Create a repository via the API. + compatibility.Request{ + Method: http.MethodPost, + URL: base + "/api/v1/user/repos", + ContentType: "application/json", + Body: `{"name":"gvisor-test","auto_init":true}`, + Username: adminUser, + Password: adminPassword, + }.DoOrFatal(t, http.StatusCreated) + + // Read the repository back via the API. + status, repoBody, err := compatibility.Get(base + "/api/v1/repos/" + adminUser + "/gvisor-test") + if err != nil { + t.Fatalf("read-back request failed: %v", err) + } + if status != http.StatusOK { + t.Fatalf("read repo back: got status %d, want %d; body: %s", status, http.StatusOK, repoBody) + } + want := `"name":"gvisor-test"` + if !strings.Contains(repoBody, want) { + t.Fatalf("read repo back: body missing %q; got: %s", want, repoBody) + } +} + +func TestMain(m *testing.M) { + dockerutil.EnsureSupportedDockerVersion() + flag.Parse() + os.Exit(m.Run()) +} diff --git a/test/compatibility/nextcloud/nextcloud_test.go b/test/compatibility/nextcloud/nextcloud_test.go new file mode 100644 index 0000000000..f832d9d899 --- /dev/null +++ b/test/compatibility/nextcloud/nextcloud_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package nextcloud is a gVisor compatibility test for Nextcloud backed by +// MariaDB. +// +// The version under test is pinned in +// images/compatibility/nextcloud/nextcloud/Dockerfile. +package nextcloud + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "gvisor.dev/gvisor/pkg/test/dockerutil" + "gvisor.dev/gvisor/test/compatibility" +) + +const ( + nextcloudImage = "compatibility/nextcloud/nextcloud" + mariadbImage = "compatibility/nextcloud/mariadb" + + dbName = "nextcloud" + dbUser = "nextcloud" + dbPassword = "ncpass" + dbRootPass = "rootpass" + + adminUser = "admin" + adminPassword = "Gvisor-Test-1234" + + trustedHost = "localhost" + nextcloudPort = 80 + + readyTimeout = 5 * time.Minute // Nextcloud's first-run install is slow. + pollInterval = 2 * time.Second +) + +func TestNextcloud(t *testing.T) { + ctx := context.Background() + + // MariaDB backend. + db := dockerutil.MakeContainer(ctx, t) + defer db.CleanUp(ctx) + if err := db.Spawn(ctx, dockerutil.RunOpts{ + Image: mariadbImage, + Env: []string{ + "MARIADB_ROOT_PASSWORD=" + dbRootPass, + "MARIADB_DATABASE=" + dbName, + "MARIADB_USER=" + dbUser, + "MARIADB_PASSWORD=" + dbPassword, + }, + }); err != nil { + t.Fatalf("failed to start mariadb: %v", err) + } + + // Wait until MariaDB is ready. + compatibility.Poll(ctx, t, "mariadb to accept queries", readyTimeout, pollInterval, func() error { + out, err := db.Exec(ctx, dockerutil.ExecOpts{}, + "mariadb", "-u"+dbUser, "-p"+dbPassword, dbName, "-e", "SELECT 1") + if err != nil { + return fmt.Errorf("mariadb query: %v (%s)", err, out) + } + return nil + }) + + // Nextcloud, linked to MariaDB. + // Admin + DB env trigger Nextcloud's unattended install on startup. + app := dockerutil.MakeContainer(ctx, t) + defer app.CleanUp(ctx) + if err := app.Spawn(ctx, dockerutil.RunOpts{ + Image: nextcloudImage, + Links: []string{db.MakeLink("db")}, + Env: []string{ + "MYSQL_HOST=db", + "MYSQL_DATABASE=" + dbName, + "MYSQL_USER=" + dbUser, + "MYSQL_PASSWORD=" + dbPassword, + "NEXTCLOUD_ADMIN_USER=" + adminUser, + "NEXTCLOUD_ADMIN_PASSWORD=" + adminPassword, + "NEXTCLOUD_TRUSTED_DOMAINS=" + trustedHost, + }, + }); err != nil { + t.Fatalf("failed to start nextcloud: %v", err) + } + + ip, err := app.FindIP(ctx, false) + if err != nil { + t.Fatalf("failed to find nextcloud IP: %v", err) + } + base := fmt.Sprintf("http://%s:%d", ip.String(), nextcloudPort) + + // Wait for the unattended install to finish. + compatibility.Poll(ctx, t, "nextcloud install to finish", readyTimeout, pollInterval, func() error { + status, body, err := compatibility.Request{URL: base + "/status.php", Host: trustedHost}.Do() + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("GET /status.php: status %d (%s)", status, body) + } + if !strings.Contains(body, `"installed":true`) { + return fmt.Errorf("not installed yet: %s", body) + } + return nil + }) + + // OCS API: fetch the current user. + if body := (compatibility.Request{ + URL: base + "/ocs/v1.php/cloud/user?format=json", + Host: trustedHost, + Username: adminUser, + Password: adminPassword, + Headers: map[string]string{"OCS-APIRequest": "true"}, + }).DoOrFatal(t, http.StatusOK); !strings.Contains(body, `"id":"`+adminUser+`"`) { + t.Fatalf("OCS user: response missing admin id; got: %s", body) + } + + // WebDAV: upload a file, then download it and verify the content round-trips. + const content = "hello from the gVisor nextcloud compatibility test" + davURL := base + "/remote.php/dav/files/" + adminUser + "/gvtest.txt" + compatibility.Request{ + Method: http.MethodPut, + URL: davURL, + Body: content, + Host: trustedHost, + Username: adminUser, + Password: adminPassword, + }.DoOrFatal(t, http.StatusCreated) + if got := (compatibility.Request{ + URL: davURL, + Host: trustedHost, + Username: adminUser, + Password: adminPassword, + }).DoOrFatal(t, http.StatusOK); got != content { + t.Fatalf("WebDAV round-trip: got %q, want %q", got, content) + } +} + +func TestMain(m *testing.M) { + dockerutil.EnsureSupportedDockerVersion() + flag.Parse() + os.Exit(m.Run()) +} diff --git a/test/compatibility/wordpress/wordpress_test.go b/test/compatibility/wordpress/wordpress_test.go new file mode 100644 index 0000000000..91711b8d9d --- /dev/null +++ b/test/compatibility/wordpress/wordpress_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package wordpress is a gVisor compatibility test for WordPress backed by MySQL. +// +// The WordPress and MySQL versions under test are pinned in +// images/compatibility/wordpress/{wordpress,mysql}/Dockerfile. +package wordpress + +import ( + "context" + "flag" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "gvisor.dev/gvisor/pkg/test/dockerutil" + "gvisor.dev/gvisor/test/compatibility" +) + +const ( + wordpressImage = "compatibility/wordpress/wordpress" + mysqlImage = "compatibility/wordpress/mysql" + + dbName = "wordpress" + dbUser = "wp" + dbPassword = "wppass" + dbRootPassword = "rootpass" + + siteTitle = "gVisor Compat Test" + adminUser = "gvisoradmin" + adminPassword = "gvisor-Test-1234!" + adminEmail = "admin@example.com" + + wpPort = 80 + + readyTimeout = 3 * time.Minute + pollInterval = 2 * time.Second +) + +func TestWordPress(t *testing.T) { + ctx := context.Background() + + // MySQL backend. + db := dockerutil.MakeContainer(ctx, t) + defer db.CleanUp(ctx) + if err := db.Spawn(ctx, dockerutil.RunOpts{ + Image: mysqlImage, + Env: []string{ + "MYSQL_ROOT_PASSWORD=" + dbRootPassword, + "MYSQL_DATABASE=" + dbName, + "MYSQL_USER=" + dbUser, + "MYSQL_PASSWORD=" + dbPassword, + }, + }); err != nil { + t.Fatalf("failed to start mysql: %v", err) + } + + // Wait until the application user can run a query. + compatibility.Poll(ctx, t, "mysql to accept queries", readyTimeout, pollInterval, func() error { + out, err := db.Exec(ctx, dockerutil.ExecOpts{}, + "mysql", "-u"+dbUser, "-p"+dbPassword, dbName, "-e", "SELECT 1") + if err != nil { + return fmt.Errorf("mysql query: %v (%s)", err, out) + } + return nil + }) + + // WordPress app container. + wp := dockerutil.MakeContainer(ctx, t) + defer wp.CleanUp(ctx) + if err := wp.Spawn(ctx, dockerutil.RunOpts{ + Image: wordpressImage, + Links: []string{db.MakeLink("db")}, + Env: []string{ + "WORDPRESS_DB_HOST=db:3306", + "WORDPRESS_DB_USER=" + dbUser, + "WORDPRESS_DB_PASSWORD=" + dbPassword, + "WORDPRESS_DB_NAME=" + dbName, + }, + }); err != nil { + t.Fatalf("failed to start wordpress: %v", err) + } + + ip, err := wp.FindIP(ctx, false) + if err != nil { + t.Fatalf("failed to find wordpress IP: %v", err) + } + base := fmt.Sprintf("http://%s:%d", ip.String(), wpPort) + + // Wait for WordPress to serve the installer. + compatibility.Poll(ctx, t, "wordpress installer to be ready", readyTimeout, pollInterval, func() error { + status, _, err := compatibility.Get(base + "/wp-admin/install.php") + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("GET /wp-admin/install.php: status %d", status) + } + return nil + }) + + // Run the install. + form := url.Values{ + "weblog_title": {siteTitle}, + "user_name": {adminUser}, + "admin_password": {adminPassword}, + "admin_password2": {adminPassword}, + "pw_weak": {"1"}, // acknowledge the password strength prompt. + "admin_email": {adminEmail}, + "blog_public": {"1"}, + "language": {""}, + "Submit": {"Install WordPress"}, + } + installed := compatibility.Request{ + Method: http.MethodPost, + URL: base + "/wp-admin/install.php?step=2", + ContentType: "application/x-www-form-urlencoded", + Body: form.Encode(), + }.DoOrFatal(t, http.StatusOK) + if !strings.Contains(installed, "Success") { + t.Fatalf("install did not report success; body: %s", installed) + } + + // The front page is rendered from the database: it must show the title we + // just stored. + front := compatibility.Request{URL: base + "/"}.DoOrFatal(t, http.StatusOK) + if !strings.Contains(front, siteTitle) { + t.Fatalf("front page missing site title %q; body: %s", siteTitle, front) + } + + // The REST API reads the default seed post back out. + posts := compatibility.Request{URL: base + "/?rest_route=/wp/v2/posts"}.DoOrFatal(t, http.StatusOK) + if !strings.Contains(posts, "Hello world") { + t.Fatalf("REST posts missing default seed post; body: %s", posts) + } + t.Logf("wordpress installed and serving %q from MySQL", siteTitle) +} + +func TestMain(m *testing.M) { + dockerutil.EnsureSupportedDockerVersion() + flag.Parse() + os.Exit(m.Run()) +}