Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions cmd/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,12 @@ Or specify the domain name:
// siteComposePath returns the compose file of the site selected by --domain
// or by the current directory.
func siteComposePath() (string, error) {
composePath := utils.FindComposeFile(domain)
if composePath != "" {
return composePath, nil
composePath, err := utils.FindComposeFile(domain)
if errors.Is(err, utils.ErrComposeNotFound) && domain == "" {
return "", errNoSite
}

if domain != "" {
return "", fmt.Errorf("no docker-compose.yml file found for domain %q", domain)
}

return "", errNoSite
return composePath, err
}

var wpCmd = &cobra.Command{
Expand Down
77 changes: 65 additions & 12 deletions internal/utils/finder.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,89 @@
package utils

import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)

func FindComposeFile(domain string) string {
homeDir, _ := os.UserHomeDir()
// ErrComposeNotFound means that the site has no docker-compose.yml file.
var ErrComposeNotFound = errors.New("no docker-compose.yml file found")

// hostnameLabel matches one DNS label: letters, digits and inner hyphens.
var hostnameLabel = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)

// ValidateDomain returns an error if domain is not a valid hostname. A valid
// hostname has no path separators and no "..", so it can only name a
// directory directly inside the sites directory.
func ValidateDomain(domain string) error {
if len(domain) == 0 || len(domain) > 253 {
return fmt.Errorf("invalid domain %q", domain)
}

for _, label := range strings.Split(domain, ".") {
if !hostnameLabel.MatchString(label) {
return fmt.Errorf("invalid domain %q", domain)
}
}

return nil
}

// FindComposeFile returns the docker-compose.yml file of a site. With a
// domain, it looks in ~/<domain>. Without one, it searches from the current
// directory up to the home directory. It returns ErrComposeNotFound if the
// site has no compose file.
func FindComposeFile(domain string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
homeDir = filepath.Clean(homeDir)

// If domain is provided, check in ~/domain/docker-compose.yml
if domain != "" {
composePath := filepath.Join(homeDir, domain, "docker-compose.yml")
if _, err := os.Stat(composePath); err == nil {
return composePath
if err := ValidateDomain(domain); err != nil {
return "", err
}
return ""

siteDir := filepath.Join(homeDir, domain)
if filepath.Dir(siteDir) != homeDir {
return "", fmt.Errorf("invalid domain %q", domain)
}

composePath := filepath.Join(siteDir, "docker-compose.yml")
if _, err := os.Stat(composePath); errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("%w for domain %q", ErrComposeNotFound, domain)
} else if err != nil {
return "", err
}

return composePath, nil
}

// Otherwise search from current directory up to home directory
dir, _ := os.Getwd()
dir, err := os.Getwd()
if err != nil {
return "", err
}

for {
composePath := filepath.Join(dir, "docker-compose.yml")

if _, err := os.Stat(composePath); err == nil {
return composePath
return composePath, nil
}

if dir == homeDir {
return ""
// Stop at the home directory, or at the filesystem root when the
// current directory is outside the home directory.
parent := filepath.Dir(dir)
if dir == homeDir || parent == dir {
return "", ErrComposeNotFound
}

dir = filepath.Dir(dir)
dir = parent
}
}
142 changes: 142 additions & 0 deletions internal/utils/finder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package utils

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/flywp/server-cli/internal/testutil"
)

// findWithTimeout fails the test if FindComposeFile does not return quickly.
func findWithTimeout(t *testing.T, domain string) (string, error) {
t.Helper()

type result struct {
path string
err error
}
done := make(chan result, 1)
go func() {
path, err := FindComposeFile(domain)
done <- result{path, err}
}()

select {
case r := <-done:
return r.path, r.err
case <-time.After(2 * time.Second):
t.Fatal("FindComposeFile did not return within 2s")
return "", nil
}
}

func TestFindComposeFileOutsideHomeStops(t *testing.T) {
root := testutil.TempDir(t)
t.Setenv("HOME", filepath.Join(root, "home"))

outside := filepath.Join(root, "var", "www")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(outside)

if _, err := findWithTimeout(t, ""); !errors.Is(err, ErrComposeNotFound) {
t.Errorf("FindComposeFile() error = %v, want ErrComposeNotFound", err)
}
}

func TestFindComposeFileFromNestedDirectory(t *testing.T) {
home := testutil.TempDir(t)
t.Setenv("HOME", home)

want := testutil.WriteSite(t, filepath.Join(home, "example.com"), "php")
nested := filepath.Join(home, "example.com", "app", "public", "wp-content")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(nested)

got, err := findWithTimeout(t, "")
if err != nil || got != want {
t.Errorf("FindComposeFile() = %q, %v, want %q, nil", got, err, want)
}
}

func TestFindComposeFileStopsAtHome(t *testing.T) {
root := testutil.TempDir(t)
testutil.WriteSite(t, root, "php") // above the home directory: not a site
home := filepath.Join(root, "home")
t.Setenv("HOME", home)

dir := filepath.Join(home, "notes")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(dir)

if got, err := findWithTimeout(t, ""); !errors.Is(err, ErrComposeNotFound) {
t.Errorf("FindComposeFile() = %q, %v, want ErrComposeNotFound", got, err)
}
}

func TestFindComposeFileWithDomain(t *testing.T) {
home := testutil.TempDir(t)
t.Setenv("HOME", home)
want := testutil.WriteSite(t, filepath.Join(home, "example.com"), "php")

if got, err := findWithTimeout(t, "example.com"); err != nil || got != want {
t.Errorf("FindComposeFile(example.com) = %q, %v, want %q, nil", got, err, want)
}

if _, err := findWithTimeout(t, "other.com"); !errors.Is(err, ErrComposeNotFound) {
t.Errorf("FindComposeFile(other.com) error = %v, want ErrComposeNotFound", err)
}

if _, err := findWithTimeout(t, "../"); err == nil || errors.Is(err, ErrComposeNotFound) {
t.Errorf("FindComposeFile(../) error = %v, want an invalid domain error", err)
}
}

func TestValidateDomain(t *testing.T) {
valid := []string{
"example.com",
"www.example.co.uk",
"my-site.example.com",
"xn--mnchen-3ya.de",
"localhost",
strings.Repeat("a", 63) + ".com",
}
for _, d := range valid {
if err := ValidateDomain(d); err != nil {
t.Errorf("ValidateDomain(%q) = %v, want nil", d, err)
}
}

invalid := []string{
"",
".",
"..",
"../",
"../etc",
"a/b",
"/etc",
".example.com",
"example..com",
"example.com.",
"-example.com",
"example-.com",
"exa mple.com",
"exa_mple.com",
strings.Repeat("a", 64) + ".com",
strings.Repeat("a.", 127) + "com",
}
for _, d := range invalid {
if err := ValidateDomain(d); err == nil {
t.Errorf("ValidateDomain(%q) = nil, want an error", d)
}
}
}
44 changes: 42 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ package main

import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/flywp/server-cli/internal/testutil"
)
Expand Down Expand Up @@ -66,11 +68,15 @@ func newEnv(t *testing.T, services ...string) *env {
return &env{home: home, site: site, docker: testutil.NewFakeDocker(t)}
}

// run executes fly in dir with the test environment.
// run executes fly in dir with the test environment. It kills fly and fails
// the test if fly does not finish within 10 seconds.
func (e *env) run(t *testing.T, dir string, args ...string) result {
t.Helper()

cmd := exec.Command(flyBin, args...)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, flyBin, args...)
cmd.Dir = dir
cmd.Env = append(append(e.docker.Env(), "HOME="+e.home), e.vars...)

Expand All @@ -82,6 +88,8 @@ func (e *env) run(t *testing.T, dir string, args ...string) result {

var exitErr *exec.ExitError
switch {
case ctx.Err() != nil:
t.Fatalf("fly %s did not finish within 10s", strings.Join(args, " "))
case errors.As(err, &exitErr):
res.code = exitErr.ExitCode()
case err != nil:
Expand Down Expand Up @@ -170,6 +178,38 @@ func TestNoSiteIsAnError(t *testing.T) {
}
}

func TestOutsideHomeDoesNotHang(t *testing.T) {
e := newEnv(t)
outside := testutil.TempDir(t) // not inside e.home

// run fails the test if fly does not stop.
res := e.run(t, outside, "start")
if res.code != 1 || !strings.Contains(res.stderr, "no docker-compose.yml file found") {
t.Errorf("got exit %d, stderr %q, want exit 1 and the no-site error", res.code, res.stderr)
}
}

func TestDomainFlag(t *testing.T) {
e := newEnv(t)
outside := testutil.TempDir(t)

res := e.run(t, outside, "start", "--domain", "example.com")
if res.code != 0 {
t.Fatalf("--domain example.com: exit code = %d, want 0 (stderr %q)", res.code, res.stderr)
}

for _, bad := range []string{"../", "..", "example.com/../..", "a/b"} {
res := e.run(t, outside, "start", "--domain", bad)
if res.code != 1 || !strings.Contains(res.stderr, "invalid domain") {
t.Errorf("--domain %q: exit %d, stderr %q, want exit 1 and an invalid domain error", bad, res.code, res.stderr)
}
}

if calls := e.docker.Calls(t); len(calls) != 1 {
t.Errorf("docker calls = %q, want only the call for example.com", calls)
}
}

func TestUsageErrors(t *testing.T) {
tests := []struct {
args []string
Expand Down