agola/internal/testutil/utils.go
Simone Gotti 576c09775f go.mod: replace https://github.com/satori/go.uuid
Replace https://github.com/satori/go.uuid with maintained version at
https://github.com/gofrs/uuid

Since the new version uuid.NewV4 returns an error when failing to read
from the random source reader we use uuid.Must to panic on error since
it's considered an unrecoverable error.

In future, if needed, we could handle the error instead of panicking.
2022-02-21 09:43:32 +01:00

590 lines
13 KiB
Go

// Copyright 2019 Sorint.lab
//
// 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 testutil
import (
"bufio"
"bytes"
"context"
"fmt"
"io/ioutil"
"net"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"sync"
"testing"
"text/template"
"time"
"agola.io/agola/internal/etcd"
"go.etcd.io/etcd/embed"
"go.uber.org/zap"
"github.com/gofrs/uuid"
"github.com/sgotti/gexpect"
)
const (
sleepInterval = 500 * time.Millisecond
etcdTimeout = 5 * time.Second
MinPort = 2048
MaxPort = 16384
)
var curPort = MinPort
var portMutex = sync.Mutex{}
type Process struct {
t *testing.T
uid string
name string
args []string
Cmd *gexpect.ExpectSubprocess
bin string
}
func (p *Process) start() error {
if p.Cmd != nil {
panic(fmt.Errorf("%s: cmd not cleanly stopped", p.uid))
}
cmd := exec.Command(p.bin, p.args...)
pr, pw, err := os.Pipe()
if err != nil {
return err
}
p.Cmd = &gexpect.ExpectSubprocess{Cmd: cmd, Output: pw}
if err := p.Cmd.Start(); err != nil {
return err
}
go func() {
scanner := bufio.NewScanner(pr)
for scanner.Scan() {
p.t.Logf("[%s %s]: %s", p.name, p.uid, scanner.Text())
}
}()
return nil
}
func (p *Process) Start() error {
if err := p.start(); err != nil {
return err
}
p.Cmd.Continue()
return nil
}
func (p *Process) StartExpect() error {
return p.start()
}
func (p *Process) Signal(sig os.Signal) error {
p.t.Logf("signalling %s %s with %s", p.name, p.uid, sig)
if p.Cmd == nil {
panic(fmt.Errorf("p: %s, cmd is empty", p.uid))
}
return p.Cmd.Cmd.Process.Signal(sig)
}
func (p *Process) Kill() {
p.t.Logf("killing %s %s", p.name, p.uid)
if p.Cmd == nil {
panic(fmt.Errorf("p: %s, cmd is empty", p.uid))
}
_ = p.Cmd.Cmd.Process.Signal(os.Kill)
_ = p.Cmd.Wait()
p.Cmd = nil
}
func (p *Process) Stop() {
p.t.Logf("stopping %s %s", p.name, p.uid)
if p.Cmd == nil {
panic(fmt.Errorf("p: %s, cmd is empty", p.uid))
}
p.Cmd.Continue()
_ = p.Cmd.Cmd.Process.Signal(os.Interrupt)
_ = p.Cmd.Wait()
p.Cmd = nil
}
func (p *Process) Wait(timeout time.Duration) error {
timeoutCh := time.NewTimer(timeout).C
endCh := make(chan error)
go func() {
err := p.Cmd.Wait()
endCh <- err
}()
select {
case <-timeoutCh:
return fmt.Errorf("timeout waiting on process")
case <-endCh:
return nil
}
}
type TestEmbeddedEtcd struct {
t *testing.T
*TestEtcd
Etcd *embed.Etcd
Endpoint string
ListenAddress string
Port string
}
func NewTestEmbeddedEtcd(t *testing.T, logger *zap.Logger, dir string, a ...string) (*TestEmbeddedEtcd, error) {
u := uuid.Must(uuid.NewV4())
uid := fmt.Sprintf("%x", u[:4])
dataDir := filepath.Join(dir, fmt.Sprintf("etcd%s", uid))
listenAddress, port, err := GetFreePort(true, false)
if err != nil {
return nil, err
}
listenAddress2, port2, err := GetFreePort(true, false)
if err != nil {
return nil, err
}
cfg := embed.NewConfig()
cfg.Name = uid
cfg.Dir = dataDir
cfg.Logger = "zap"
cfg.LogLevel = "fatal"
cfg.LogOutputs = []string{"stdout"}
lcurl, _ := url.Parse(fmt.Sprintf("http://%s:%s", listenAddress, port))
lpurl, _ := url.Parse(fmt.Sprintf("http://%s:%s", listenAddress2, port2))
cfg.LCUrls = []url.URL{*lcurl}
cfg.ACUrls = []url.URL{*lcurl}
cfg.LPUrls = []url.URL{*lpurl}
cfg.APUrls = []url.URL{*lpurl}
cfg.InitialCluster = cfg.InitialClusterFromName(cfg.Name)
t.Logf("starting embedded etcd server")
embeddedEtcd, err := embed.StartEtcd(cfg)
if err != nil {
return nil, err
}
storeEndpoint := fmt.Sprintf("http://%s:%s", listenAddress, port)
storeConfig := etcd.Config{
Logger: logger,
Endpoints: storeEndpoint,
}
e, err := etcd.New(storeConfig)
if err != nil {
return nil, fmt.Errorf("cannot create store: %v", err)
}
tectd := &TestEmbeddedEtcd{
t: t,
TestEtcd: &TestEtcd{
e,
t,
},
Etcd: embeddedEtcd,
Endpoint: storeEndpoint,
ListenAddress: listenAddress,
Port: port,
}
return tectd, nil
}
func (te *TestEmbeddedEtcd) Start() error {
<-te.Etcd.Server.ReadyNotify()
return nil
}
func (te *TestEmbeddedEtcd) Stop() error {
te.Etcd.Close()
return nil
}
func (te *TestEmbeddedEtcd) Kill() error {
te.Etcd.Close()
return nil
}
type TestExternalEtcd struct {
t *testing.T
*TestEtcd
Process
Endpoint string
ListenAddress string
Port string
}
func NewTestExternalEtcd(t *testing.T, logger *zap.Logger, dir string, a ...string) (*TestExternalEtcd, error) {
u := uuid.Must(uuid.NewV4())
uid := fmt.Sprintf("%x", u[:4])
dataDir := filepath.Join(dir, fmt.Sprintf("etcd%s", uid))
listenAddress, port, err := GetFreePort(true, false)
if err != nil {
return nil, err
}
listenAddress2, port2, err := GetFreePort(true, false)
if err != nil {
return nil, err
}
args := []string{}
args = append(args, fmt.Sprintf("--name=%s", uid))
args = append(args, fmt.Sprintf("--data-dir=%s", dataDir))
args = append(args, fmt.Sprintf("--listen-client-urls=http://%s:%s", listenAddress, port))
args = append(args, fmt.Sprintf("--advertise-client-urls=http://%s:%s", listenAddress, port))
args = append(args, fmt.Sprintf("--listen-peer-urls=http://%s:%s", listenAddress2, port2))
args = append(args, fmt.Sprintf("--initial-advertise-peer-urls=http://%s:%s", listenAddress2, port2))
args = append(args, fmt.Sprintf("--initial-cluster=%s=http://%s:%s", uid, listenAddress2, port2))
args = append(args, a...)
storeEndpoint := fmt.Sprintf("http://%s:%s", listenAddress, port)
storeConfig := etcd.Config{
Logger: logger,
Endpoints: storeEndpoint,
}
e, err := etcd.New(storeConfig)
if err != nil {
return nil, fmt.Errorf("cannot create store: %v", err)
}
bin := os.Getenv("ETCD_BIN")
if bin == "" {
return nil, fmt.Errorf("missing ETCD_BIN env")
}
tectd := &TestExternalEtcd{
t: t,
TestEtcd: &TestEtcd{
e,
t,
},
Process: Process{
t: t,
uid: uid,
name: "etcd",
bin: bin,
args: args,
},
Endpoint: storeEndpoint,
ListenAddress: listenAddress,
Port: port,
}
return tectd, nil
}
type TestEtcd struct {
*etcd.Store
t *testing.T
}
func (te *TestEtcd) Compact() error {
ctx, cancel := context.WithTimeout(context.Background(), etcdTimeout)
defer cancel()
resp, err := te.Get(ctx, "anykey", 0)
if err != nil && err != etcd.ErrKeyNotFound {
return err
}
_, err = te.Client().Compact(ctx, resp.Header.Revision)
return err
}
func (te *TestEtcd) WaitUp(timeout time.Duration) error {
start := time.Now()
for time.Now().Add(-timeout).Before(start) {
ctx, cancel := context.WithTimeout(context.Background(), etcdTimeout)
defer cancel()
_, err := te.Get(ctx, "anykey", 0)
if err != nil && err == etcd.ErrKeyNotFound {
return nil
}
if err == nil {
return nil
}
time.Sleep(sleepInterval)
}
return fmt.Errorf("timeout")
}
func (te *TestEtcd) WaitDown(timeout time.Duration) error {
start := time.Now()
for time.Now().Add(-timeout).Before(start) {
ctx, cancel := context.WithTimeout(context.Background(), etcdTimeout)
defer cancel()
_, err := te.Get(ctx, "anykey", 0)
if err != nil && err != etcd.ErrKeyNotFound {
return nil
}
time.Sleep(sleepInterval)
}
return fmt.Errorf("timeout")
}
const (
giteaAppIniTmpl = `
APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
RUN_USER = {{ .User }}
[repository]
ROOT = {{ .Data }}/git/repositories
[repository.local]
LOCAL_COPY_PATH = {{ .Data }}/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = {{ .Data }}/gitea/uploads
[server]
APP_DATA_PATH = {{ .Data }}/gitea
SSH_DOMAIN = {{ .SSHListenAddress }}
HTTP_PORT = {{ .HTTPPort }}
ROOT_URL = http://{{ .HTTPListenAddress }}:{{ .HTTPPort }}/
DISABLE_SSH = false
# Use built-in ssh server
START_SSH_SERVER = true
SSH_PORT = {{ .SSHPort }}
LFS_CONTENT_PATH = {{ .Data }}/git/lfs
DOMAIN = localhost
LFS_START_SERVER = true
LFS_JWT_SECRET = PI0Tfn0OcYpzpNb_u11JdoUfDbsMa2x6paWH2ckMVrw
OFFLINE_MODE = false
[database]
PATH = {{ .Data }}/gitea/gitea.db
DB_TYPE = sqlite3
[indexer]
ISSUE_INDEXER_PATH = {{ .Data }}/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = {{ .Data }}/gitea/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = {{ .Data }}/gitea/avatars
DISABLE_GRAVATAR = false
ENABLE_FEDERATED_AVATAR = true
[attachment]
PATH = {{ .Data }}/gitea/attachments
[log]
ROOT_PATH = {{ .Data }}/gitea/log
MODE = file
LEVEL = info
[security]
INSTALL_LOCK = true
SECRET_KEY = vRCH8usxWj6e8JGBPBaqycpfVyWm079xC3P3k76YsjKbrgBmyHhQD9UyzRFICKBT
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1NTc0MDI0MDZ9.27f4bakIxBIOoO48ORyLmbvpQprsJMEHLM6PyXIqB5g
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.example.org
[oauth2]
JWT_SECRET = hQdtj6H6lsd8vG6V1vCPYcOn8uP2C3i_bbnDozfCcIY
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
`
)
type GiteaConfig struct {
Data string
User string
HTTPListenAddress string
HTTPPort string
SSHListenAddress string
SSHPort string
}
type TestGitea struct {
Process
GiteaPath string
ConfigPath string
HTTPListenAddress string
HTTPPort string
SSHListenAddress string
SSHPort string
}
func NewTestGitea(t *testing.T, dir, dockerBridgeAddress string, a ...string) (*TestGitea, error) {
u := uuid.Must(uuid.NewV4())
uid := fmt.Sprintf("%x", u[:4])
giteaPath := os.Getenv("GITEA_PATH")
if giteaPath == "" {
t.Fatalf("env var GITEA_PATH is undefined")
}
curUser, err := user.Current()
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
giteaDir := filepath.Join(dir, "gitea")
_, httpPort, err := GetFreePort(true, false)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
listenAddress, sshPort, err := GetFreePort(true, false)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
giteaConfig := &GiteaConfig{
Data: giteaDir,
User: curUser.Username,
HTTPListenAddress: listenAddress,
SSHListenAddress: dockerBridgeAddress,
HTTPPort: httpPort,
SSHPort: sshPort,
}
tmpl, err := template.New("gitea").Parse(giteaAppIniTmpl)
if err != nil {
return nil, err
}
conf := &bytes.Buffer{}
if err := tmpl.Execute(conf, giteaConfig); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "gitea", "conf"), 0775); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "gitea", "log"), 0775); err != nil {
return nil, err
}
configPath := filepath.Join(dir, "gitea", "conf", "app.ini")
if err := ioutil.WriteFile(configPath, conf.Bytes(), 0664); err != nil {
return nil, err
}
args := []string{}
args = append(args, "web", "--config", configPath)
tgitea := &TestGitea{
Process: Process{
t: t,
uid: uid,
name: "gitea",
bin: giteaPath,
args: args,
},
GiteaPath: giteaPath,
ConfigPath: configPath,
HTTPListenAddress: listenAddress,
HTTPPort: httpPort,
SSHListenAddress: dockerBridgeAddress,
SSHPort: sshPort,
}
return tgitea, nil
}
type CheckFunc func() (bool, error)
func Wait(timeout time.Duration, f CheckFunc) error {
start := time.Now()
for time.Now().Add(-timeout).Before(start) {
ok, err := f()
if err != nil {
return err
}
if ok {
return nil
}
time.Sleep(sleepInterval)
}
return fmt.Errorf("timeout")
}
func testFreeTCPPort(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return err
}
ln.Close()
return nil
}
func testFreeUDPPort(port int) error {
ln, err := net.ListenPacket("udp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return err
}
ln.Close()
return nil
}
// Hack to find a free tcp and udp port
func GetFreePort(tcp bool, udp bool) (string, string, error) {
portMutex.Lock()
defer portMutex.Unlock()
if !tcp && !udp {
return "", "", fmt.Errorf("at least one of tcp or udp port shuld be required")
}
localhostIP, err := net.ResolveIPAddr("ip", "localhost")
if err != nil {
return "", "", fmt.Errorf("failed to resolve ip addr: %v", err)
}
for {
curPort++
if curPort > MaxPort {
return "", "", fmt.Errorf("all available ports to test have been exausted")
}
if tcp {
if err := testFreeTCPPort(curPort); err != nil {
continue
}
}
if udp {
if err := testFreeUDPPort(curPort); err != nil {
continue
}
}
return localhostIP.IP.String(), strconv.Itoa(curPort), nil
}
}