d2b09d854f
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.
591 lines
14 KiB
Go
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
|
|
}
|
|
}
|