agola/internal/testutil/utils.go
Simone Gotti d2b09d854f *: use new errors handling library
Implement a new error handling library based on pkg/errors. It provides
stack saving on wrapping and exports some function to add stack saving
also to external errors.
It also implements custom zerolog error formatting without adding too
much verbosity by just printing the chain error file:line without a full
stack trace of every error.

* Add a --detailed-errors options to print error with they full chain
* Wrap all error returns. Use errors.WithStack to wrap without adding a
  new messsage and error.Wrap[f] to add a message.
* Add golangci-lint wrapcheck to check that external packages errors are
  wrapped. This won't check that internal packages error are wrapped.
  But we want also to ensure this case so we'll have to find something
  else to check also these.
2022-02-28 12:49:13 +01:00

591 lines
14 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/errors"
"agola.io/agola/internal/etcd"
"github.com/rs/zerolog"
"go.etcd.io/etcd/embed"
"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(errors.Errorf("%s: cmd not cleanly stopped", p.uid))
}
cmd := exec.Command(p.bin, p.args...)
pr, pw, err := os.Pipe()
if err != nil {
return errors.WithStack(err)
}
p.Cmd = &gexpect.ExpectSubprocess{Cmd: cmd, Output: pw}
if err := p.Cmd.Start(); err != nil {
return errors.WithStack(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 errors.WithStack(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(errors.Errorf("p: %s, cmd is empty", p.uid))
}
return errors.WithStack(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(errors.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(errors.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 errors.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, log zerolog.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, errors.WithStack(err)
}
listenAddress2, port2, err := GetFreePort(true, false)
if err != nil {
return nil, errors.WithStack(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, errors.WithStack(err)
}
storeEndpoint := fmt.Sprintf("http://%s:%s", listenAddress, port)
storeConfig := etcd.Config{
Log: log,
Endpoints: storeEndpoint,
}
e, err := etcd.New(storeConfig)
if err != nil {
return nil, errors.Wrapf(err, "cannot create store")
}
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, log zerolog.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, errors.WithStack(err)
}
listenAddress2, port2, err := GetFreePort(true, false)
if err != nil {
return nil, errors.WithStack(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{
Log: log,
Endpoints: storeEndpoint,
}
e, err := etcd.New(storeConfig)
if err != nil {
return nil, errors.Wrapf(err, "cannot create store")
}
bin := os.Getenv("ETCD_BIN")
if bin == "" {
return nil, errors.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 && !errors.Is(err, etcd.ErrKeyNotFound) {
return errors.WithStack(err)
}
_, err = te.Client().Compact(ctx, resp.Header.Revision)
return errors.WithStack(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 && errors.Is(err, etcd.ErrKeyNotFound) {
return nil
}
if err == nil {
return nil
}
time.Sleep(sleepInterval)
}
return errors.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 && !errors.Is(err, etcd.ErrKeyNotFound) {
return nil
}
time.Sleep(sleepInterval)
}
return errors.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, errors.WithStack(err)
}
conf := &bytes.Buffer{}
if err := tmpl.Execute(conf, giteaConfig); err != nil {
return nil, errors.WithStack(err)
}
if err := os.MkdirAll(filepath.Join(dir, "gitea", "conf"), 0775); err != nil {
return nil, errors.WithStack(err)
}
if err := os.MkdirAll(filepath.Join(dir, "gitea", "log"), 0775); err != nil {
return nil, errors.WithStack(err)
}
configPath := filepath.Join(dir, "gitea", "conf", "app.ini")
if err := ioutil.WriteFile(configPath, conf.Bytes(), 0664); err != nil {
return nil, errors.WithStack(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 errors.WithStack(err)
}
if ok {
return nil
}
time.Sleep(sleepInterval)
}
return errors.Errorf("timeout")
}
func testFreeTCPPort(port int) error {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return errors.WithStack(err)
}
ln.Close()
return nil
}
func testFreeUDPPort(port int) error {
ln, err := net.ListenPacket("udp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return errors.WithStack(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 "", "", errors.Errorf("at least one of tcp or udp port shuld be required")
}
localhostIP, err := net.ResolveIPAddr("ip", "localhost")
if err != nil {
return "", "", errors.Wrapf(err, "failed to resolve ip addr")
}
for {
curPort++
if curPort > MaxPort {
return "", "", errors.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
}
}