From 172dcd718e2c9bbc70b8a2b5ccf19e0efa74449e Mon Sep 17 00:00:00 2001 From: Ryan El Kochta Date: Tue, 7 Jul 2026 17:46:35 -0700 Subject: [PATCH] Compatibility test suite: add WordPress, Gitea, Nextcloud The first stage of the gVisor application compatibility test suite. This commit adds: - A compatibility testing helper library, containing e.g. logic to write service config files, wait for a service to be up, and make HTTP requests - Initial tests for WordPress 7.0.0, Gitea 1.26.2, and Nextcloud 34.0.0. A large library of test stacks is ready, however I am splitting them into smaller commits in the interest of review size. Tests first consist of a stack of images under images/compatibility/. This is done both for consistency with the existing image tests and so that the Dockerfiles can be modified, for example to install client or benchmarking tools to aid with testing. The tests themselves are written in Go and placed in test/compatibility/. These tests are kept separate from the existing image tests for a few reasons. First, the image tests mainly exist to find gVisor regressions; however, a core goal of the compatibility tests is to detect when updates to *third-party applications* cause them to stop working in gVisor. Second, it's probably not a good idea to include the compatibility tests in our CI/CD pipeline (at least, not yet) due to concerns about flaky tests and/or increased iteration time. (Maybe they should be run before a release is cut, though?) Once the compatibility tests are merged, I'll submit the website portion which will list the applications we have tested to be compatible. Each entry will contain a link to its corresponding compatibility test in our GitHub repository. test/compatibility/README.md is included with instructions on running the tests. --- images/compatibility/gitea/gitea/Dockerfile | 1 + .../compatibility/gitea/postgres/Dockerfile | 1 + .../nextcloud/mariadb/Dockerfile | 1 + .../nextcloud/nextcloud/Dockerfile | 1 + .../compatibility/wordpress/mysql/Dockerfile | 1 + .../wordpress/wordpress/Dockerfile | 1 + test/compatibility/BUILD | 41 +++++ test/compatibility/README.md | 33 ++++ test/compatibility/compatibility.go | 156 +++++++++++++++++ test/compatibility/defs.bzl | 46 +++++ test/compatibility/gitea/gitea_test.go | 160 ++++++++++++++++++ .../compatibility/nextcloud/nextcloud_test.go | 160 ++++++++++++++++++ .../compatibility/wordpress/wordpress_test.go | 159 +++++++++++++++++ 13 files changed, 761 insertions(+) create mode 100644 images/compatibility/gitea/gitea/Dockerfile create mode 100644 images/compatibility/gitea/postgres/Dockerfile create mode 100644 images/compatibility/nextcloud/mariadb/Dockerfile create mode 100644 images/compatibility/nextcloud/nextcloud/Dockerfile create mode 100644 images/compatibility/wordpress/mysql/Dockerfile create mode 100644 images/compatibility/wordpress/wordpress/Dockerfile create mode 100644 test/compatibility/BUILD create mode 100644 test/compatibility/README.md create mode 100644 test/compatibility/compatibility.go create mode 100644 test/compatibility/defs.bzl create mode 100644 test/compatibility/gitea/gitea_test.go create mode 100644 test/compatibility/nextcloud/nextcloud_test.go create mode 100644 test/compatibility/wordpress/wordpress_test.go 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()) +}