v1.0.0
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
// Package app provides application lifecycle primitives.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// NewContext returns a context for the whole application lifecycle.
|
||||
//
|
||||
// The context is cancelled when the parent is cancelled or when the process
|
||||
// receives an interrupt or a termination signal. Call the returned function
|
||||
// during shutdown to unregister signal notifications and release resources.
|
||||
func NewContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewContextCancelsWithParent(t *testing.T) {
|
||||
parent, cancelParent := context.WithCancel(context.Background())
|
||||
ctx, stop := NewContext(parent)
|
||||
t.Cleanup(stop)
|
||||
|
||||
cancelParent()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("application context was not cancelled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package config loads est tunnel configuration and command-line options.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ProxyDirection identifies the direction of an SSH port forward.
|
||||
type ProxyDirection string
|
||||
|
||||
const (
|
||||
// RemoteToLocal creates a local SSH forward with the -L option.
|
||||
RemoteToLocal ProxyDirection = "rtl"
|
||||
// LocalToRemote creates a remote SSH forward with the -R option.
|
||||
LocalToRemote ProxyDirection = "ltr"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetryCount is the number of reconnect attempts used when retry is
|
||||
// not specified.
|
||||
DefaultRetryCount = 5
|
||||
// DefaultRetryDelay is the wait between reconnect attempts when retry is
|
||||
// not specified.
|
||||
DefaultRetryDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidRetryCount is returned when retry count is less than -1.
|
||||
ErrInvalidRetryCount = errors.New("retry count must be -1 or greater")
|
||||
// ErrInvalidRetryDelay is returned when retry delay is invalid or not positive.
|
||||
ErrInvalidRetryDelay = errors.New("retry delay must be a positive duration")
|
||||
)
|
||||
|
||||
// RetryConfig configures tunnel reconnection behavior.
|
||||
// Count is the number of attempts after the initial SSH process exits; -1
|
||||
// retries indefinitely. Delay uses time.ParseDuration syntax, such as "5s".
|
||||
type RetryConfig struct {
|
||||
Count *int `toml:"count"`
|
||||
Delay string `toml:"delay"`
|
||||
}
|
||||
|
||||
// RetrySettings is a validated retry configuration ready for execution.
|
||||
type RetrySettings struct {
|
||||
Count int
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
// Settings resolves RetryConfig fields with est's default retry values.
|
||||
func (retry RetryConfig) Settings() (RetrySettings, error) {
|
||||
settings := RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}
|
||||
if retry.Count != nil {
|
||||
settings.Count = *retry.Count
|
||||
}
|
||||
if settings.Count < -1 {
|
||||
return RetrySettings{}, ErrInvalidRetryCount
|
||||
}
|
||||
if retry.Delay != "" {
|
||||
delay, err := time.ParseDuration(retry.Delay)
|
||||
if err != nil || delay <= 0 {
|
||||
return RetrySettings{}, ErrInvalidRetryDelay
|
||||
}
|
||||
settings.Delay = delay
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// ProxyEntry defines one SSH tunnel.
|
||||
type ProxyEntry struct {
|
||||
Direction ProxyDirection `toml:"direction"`
|
||||
RemotePort uint16 `toml:"remotePort"`
|
||||
RemoteIP string `toml:"remoteIP"`
|
||||
LocalPort uint16 `toml:"localPort"`
|
||||
LocalIP string `toml:"localIP"`
|
||||
Host string `toml:"host"`
|
||||
|
||||
IdentityFile string `toml:"identityFile,omitempty"`
|
||||
Retry *RetryConfig `toml:"retry"`
|
||||
}
|
||||
|
||||
// Config is the TOML configuration used by est.
|
||||
type Config struct {
|
||||
SSHConfig string `toml:"sshConfig"`
|
||||
Retry *RetryConfig `toml:"retry"`
|
||||
Entries []ProxyEntry `toml:"entry"`
|
||||
}
|
||||
|
||||
// RetrySettings resolves the retry configuration for entry. An entry retry
|
||||
// block fully overrides the top-level retry block.
|
||||
func (cfg Config) RetrySettings(entry ProxyEntry) (RetrySettings, error) {
|
||||
if entry.Retry != nil {
|
||||
return entry.Retry.Settings()
|
||||
}
|
||||
if cfg.Retry != nil {
|
||||
return cfg.Retry.Settings()
|
||||
}
|
||||
return RetryConfig{}.Settings()
|
||||
}
|
||||
|
||||
// LoadConfig decodes a TOML configuration file from path.
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
var config Config
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := toml.NewDecoder(file).Decode(&config); err != nil {
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tunnels.toml")
|
||||
contents := `sshConfig = "/etc/ssh/est.conf"
|
||||
|
||||
[retry]
|
||||
count = 3
|
||||
delay = "2s"
|
||||
|
||||
[[entry]]
|
||||
direction = "ltr"
|
||||
host = "vps"
|
||||
localPort = 22
|
||||
remotePort = 2222
|
||||
identityFile = "/keys/vps"
|
||||
|
||||
[entry.retry]
|
||||
count = -1
|
||||
delay = "100ms"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.SSHConfig != "/etc/ssh/est.conf" {
|
||||
t.Errorf("SSHConfig = %q, want %q", cfg.SSHConfig, "/etc/ssh/est.conf")
|
||||
}
|
||||
if len(cfg.Entries) != 1 {
|
||||
t.Fatalf("entries = %d, want 1", len(cfg.Entries))
|
||||
}
|
||||
entry := cfg.Entries[0]
|
||||
if entry.Direction != LocalToRemote || entry.Host != "vps" || entry.LocalPort != 22 || entry.RemotePort != 2222 || entry.IdentityFile != "/keys/vps" {
|
||||
t.Errorf("entry = %#v, want decoded tunnel values", entry)
|
||||
}
|
||||
retry, err := cfg.RetrySettings(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrySettings() error = %v", err)
|
||||
}
|
||||
if retry.Count != -1 || retry.Delay != 100*time.Millisecond {
|
||||
t.Errorf("entry retry = %#v, want count=-1 delay=100ms", retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRetrySettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
entry ProxyEntry
|
||||
want RetrySettings
|
||||
}{
|
||||
{name: "defaults", want: RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}},
|
||||
{name: "top level", cfg: Config{Retry: &RetryConfig{Count: intPtr(2), Delay: "1s"}}, want: RetrySettings{Count: 2, Delay: time.Second}},
|
||||
{name: "entry override", cfg: Config{Retry: &RetryConfig{Count: intPtr(2), Delay: "1s"}}, entry: ProxyEntry{Retry: &RetryConfig{Count: intPtr(0), Delay: "50ms"}}, want: RetrySettings{Count: 0, Delay: 50 * time.Millisecond}},
|
||||
{name: "entry override uses defaults", cfg: Config{Retry: &RetryConfig{Count: intPtr(-1), Delay: "1s"}}, entry: ProxyEntry{Retry: &RetryConfig{}}, want: RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.cfg.RetrySettings(tt.entry)
|
||||
if err != nil {
|
||||
t.Fatalf("RetrySettings() error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("RetrySettings() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryConfigSettingsRejectsInvalidValues(t *testing.T) {
|
||||
for _, retry := range []RetryConfig{
|
||||
{Count: intPtr(-2)},
|
||||
{Delay: "not-a-duration"},
|
||||
{Delay: "0s"},
|
||||
} {
|
||||
if _, err := retry.Settings(); err == nil {
|
||||
t.Errorf("Settings() error = nil for %#v", retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMissingFile(t *testing.T) {
|
||||
_, err := LoadConfig(filepath.Join(t.TempDir(), "missing.toml"))
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("LoadConfig() error = %v, want not-exist error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Flags contains command-line options accepted by est.
|
||||
type Flags struct {
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// ParseFlags parses est command-line flags.
|
||||
func ParseFlags() (*Flags, error) {
|
||||
configPath := pflag.StringP("config", "c", "./config.toml", "toml config path")
|
||||
pflag.Parse()
|
||||
if configPath == nil {
|
||||
return nil, errors.New("no config path")
|
||||
}
|
||||
return &Flags{ConfigPath: *configPath}, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package ssh builds and runs SSH port-forwarding commands.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||
)
|
||||
|
||||
func buildRemoteToLocalString(sourceIP, remoteIP string, sourcePort, remotePort uint16) string {
|
||||
if sourceIP == "" {
|
||||
sourceIP = "127.0.0.1"
|
||||
}
|
||||
if remoteIP == "" {
|
||||
remoteIP = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("%s:%d:%s:%d", sourceIP, sourcePort, remoteIP, remotePort)
|
||||
}
|
||||
func buildLocalToRemoteString(sourceIP, remoteIP string, sourcePort, remotePort uint16) string {
|
||||
if sourceIP == "" {
|
||||
sourceIP = "127.0.0.1"
|
||||
}
|
||||
if remoteIP == "" {
|
||||
remoteIP = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("%s:%d:%s:%d", remoteIP, remotePort, sourceIP, sourcePort)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrEmptyHost is returned when a tunnel has no SSH destination.
|
||||
ErrEmptyHost = errors.New("empty host")
|
||||
// ErrEmptyLocalPort is returned when a tunnel has no local port.
|
||||
ErrEmptyLocalPort = errors.New("empty local port")
|
||||
// ErrEmptyRemotePort is returned when a tunnel has no remote port.
|
||||
ErrEmptyRemotePort = errors.New("empty remote port")
|
||||
)
|
||||
|
||||
func validateArgs(entry config.ProxyEntry) error {
|
||||
if entry.Host == "" {
|
||||
return ErrEmptyHost
|
||||
}
|
||||
if entry.LocalPort == 0 {
|
||||
return ErrEmptyLocalPort
|
||||
}
|
||||
if entry.RemotePort == 0 {
|
||||
return ErrEmptyRemotePort
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func prepareArgs(configPath string, entry config.ProxyEntry) ([]string, error) {
|
||||
if err := validateArgs(entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{"-N", "-T", "-o", "ExitOnForwardFailure=yes"}
|
||||
if configPath != "" {
|
||||
args = append(args, "-F", configPath)
|
||||
} else if configPath = getConfigOrEmpty(); configPath != "" {
|
||||
args = append(args, "-F", configPath)
|
||||
}
|
||||
if entry.IdentityFile != "" {
|
||||
args = append(args, "-i", entry.IdentityFile)
|
||||
}
|
||||
switch entry.Direction {
|
||||
case config.LocalToRemote:
|
||||
args = append(args, "-R", buildLocalToRemoteString(entry.LocalIP, entry.RemoteIP, entry.LocalPort, entry.RemotePort))
|
||||
case config.RemoteToLocal:
|
||||
args = append(args, "-L", buildRemoteToLocalString(entry.LocalIP, entry.RemoteIP, entry.LocalPort, entry.RemotePort))
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid direction: %v; should be ltr on rtl", entry.Direction)
|
||||
}
|
||||
if entry.Host != "" {
|
||||
args = append(args, entry.Host)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||
)
|
||||
|
||||
func TestPrepareArgsRemoteForwardUsesLoopbackDefaults(t *testing.T) {
|
||||
args, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||
Direction: config.LocalToRemote,
|
||||
Host: "vps",
|
||||
LocalPort: 22,
|
||||
RemotePort: 2222,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareArgs() error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"-N", "-T", "-o", "ExitOnForwardFailure=yes",
|
||||
"-F", "/etc/ssh/est.conf",
|
||||
"-R", "127.0.0.1:2222:127.0.0.1:22",
|
||||
"vps",
|
||||
}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Errorf("prepareArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareArgsLocalForwardWithIdentityFile(t *testing.T) {
|
||||
args, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||
Direction: config.RemoteToLocal,
|
||||
Host: "db-vps",
|
||||
LocalIP: "127.0.0.2",
|
||||
LocalPort: 5433,
|
||||
RemoteIP: "10.0.0.10",
|
||||
RemotePort: 5432,
|
||||
IdentityFile: "/keys/db-vps",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareArgs() error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"-N", "-T", "-o", "ExitOnForwardFailure=yes",
|
||||
"-F", "/etc/ssh/est.conf",
|
||||
"-i", "/keys/db-vps",
|
||||
"-L", "127.0.0.2:5433:10.0.0.10:5432",
|
||||
"db-vps",
|
||||
}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Errorf("prepareArgs() = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareArgsRejectsInvalidEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry config.ProxyEntry
|
||||
want error
|
||||
}{
|
||||
{name: "missing host", entry: config.ProxyEntry{LocalPort: 1, RemotePort: 2}, want: ErrEmptyHost},
|
||||
{name: "missing local port", entry: config.ProxyEntry{Host: "vps", RemotePort: 2}, want: ErrEmptyLocalPort},
|
||||
{name: "missing remote port", entry: config.ProxyEntry{Host: "vps", LocalPort: 1}, want: ErrEmptyRemotePort},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := prepareArgs("/etc/ssh/est.conf", tt.entry)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Errorf("prepareArgs() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareArgsRejectsUnknownDirection(t *testing.T) {
|
||||
_, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||
Direction: "unknown",
|
||||
Host: "vps",
|
||||
LocalPort: 1,
|
||||
RemotePort: 2,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid direction") {
|
||||
t.Fatalf("prepareArgs() error = %v, want invalid-direction error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func getConfigOrEmpty() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
_ = file.Close()
|
||||
return configPath
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||
)
|
||||
|
||||
var errTunnelExited = errors.New("ssh tunnel exited unexpectedly")
|
||||
|
||||
// RunTunnel starts an SSH process for entry and waits for it to exit.
|
||||
// When SSH exits, it reconnects according to retry.
|
||||
// The process and any pending retry wait are terminated when ctx is cancelled.
|
||||
func RunTunnel(ctx context.Context, configPath string, entry config.ProxyEntry, retry config.RetrySettings) error {
|
||||
args, err := prepareArgs(configPath, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runWithRetry(ctx, retry.Count, retry.Delay, func() error {
|
||||
return runTunnelOnce(ctx, args)
|
||||
}, func(attempt int, err error) {
|
||||
if retry.Count == -1 {
|
||||
log.Printf("ssh tunnel %q failed; retrying in %s (attempt %d): %v", entry.Host, retry.Delay, attempt, err)
|
||||
return
|
||||
}
|
||||
log.Printf("ssh tunnel %q failed; retrying in %s (%d/%d): %v", entry.Host, retry.Delay, attempt, retry.Count, err)
|
||||
})
|
||||
}
|
||||
|
||||
func runWithRetry(ctx context.Context, retries int, delay time.Duration, run func() error, onRetry func(int, error)) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for attempt := 0; ; {
|
||||
err := run()
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if retries != -1 && attempt >= retries {
|
||||
return err
|
||||
}
|
||||
|
||||
attempt++
|
||||
if onRetry != nil {
|
||||
onRetry(attempt, err)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runTunnelOnce(ctx context.Context, args []string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("ssh stopped: %w", ctx.Err())
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf(
|
||||
"ssh exit code %d: %s",
|
||||
exitErr.ExitCode(),
|
||||
strings.TrimSpace(stderr.String()),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("can't run ssh: %w", err)
|
||||
}
|
||||
return errTunnelExited
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunWithRetryStopsAfterConfiguredRetries(t *testing.T) {
|
||||
wantErr := errors.New("ssh exited")
|
||||
calls := 0
|
||||
err := runWithRetry(context.Background(), 2, 0, func() error {
|
||||
calls++
|
||||
return wantErr
|
||||
}, nil)
|
||||
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runWithRetry() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("run() calls = %d, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryDoesNotRetryWhenDisabled(t *testing.T) {
|
||||
wantErr := errors.New("ssh exited")
|
||||
calls := 0
|
||||
err := runWithRetry(context.Background(), 0, 0, func() error {
|
||||
calls++
|
||||
return wantErr
|
||||
}, nil)
|
||||
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runWithRetry() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("run() calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryRetriesIndefinitelyUntilSuccess(t *testing.T) {
|
||||
calls := 0
|
||||
err := runWithRetry(context.Background(), -1, 0, func() error {
|
||||
calls++
|
||||
if calls < 4 {
|
||||
return errors.New("temporary ssh failure")
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("runWithRetry() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Errorf("run() calls = %d, want 4", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithRetryStopsWaitingWhenContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
firstCall := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runWithRetry(ctx, -1, time.Hour, func() error {
|
||||
close(firstCall)
|
||||
return errors.New("temporary ssh failure")
|
||||
}, nil)
|
||||
}()
|
||||
|
||||
<-firstCall
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("runWithRetry() error = %v, want context cancellation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("runWithRetry() did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user