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
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,33 +249,76 @@ func TestWithFTP(t *testing.T) {
ctx := context.Background()
ftpContainer := containers.NewFTPTestContainer(ctx, t)
defer ftpContainer.Close(ctx)

// Connection details
ftpHost := ftpContainer.GetIP() // Container host
ftpPort := ftpContainer.GetPort() // Container port (default: 2121)
ftpUser := ftpContainer.GetUser() // Default: "ftpuser"
ftpPassword := ftpContainer.GetPassword() // Default: "ftppass"

// Upload a file
localFile := "/path/to/local/file.txt"
localFile := "/path/to/local/file.txt"
remotePath := "file.txt"
err := ftpContainer.SaveFile(ctx, localFile, remotePath)
require.NoError(t, err)

// Download a file
downloadPath := "/path/to/download/location.txt"
err = ftpContainer.GetFile(ctx, remotePath, downloadPath)
require.NoError(t, err)

// List files
entries, err := ftpContainer.ListFiles(ctx, "/")
require.NoError(t, err)
for _, entry := range entries {
fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory
}

// Delete a file
err = ftpContainer.DeleteFile(ctx, remotePath)
require.NoError(t, err)
}
```

// Using containers in TestMain for shared container across all tests
// All containers have E-suffix variants that return errors instead of using require.NoError
var pgContainer *containers.PostgresTestContainer

func TestMain(m *testing.M) {
ctx := context.Background()

var err error
pgContainer, err = containers.NewPostgresTestContainerE(ctx)
if err != nil {
log.Fatalf("failed to start postgres container: %v", err)
}

code := m.Run()

pgContainer.Close(ctx)
os.Exit(code)
}

func TestWithSharedContainer(t *testing.T) {
// use pgContainer.ConnectionString() to connect
db, err := sql.Open("postgres", pgContainer.ConnectionString())
require.NoError(t, err)
defer db.Close()
// ...
}
```

### Error-Returning Container Variants (E-suffix)

All container constructors have E-suffix variants that return `(*Container, error)` instead of using `require.NoError`. This is useful for `TestMain` where `*testing.T` is not available:

| Standard | Error-returning |
|----------|-----------------|
| `NewPostgresTestContainer` | `NewPostgresTestContainerE` |
| `NewPostgresTestContainerWithDB` | `NewPostgresTestContainerWithDBE` |
| `NewMySQLTestContainer` | `NewMySQLTestContainerE` |
| `NewMySQLTestContainerWithDB` | `NewMySQLTestContainerWithDBE` |
| `NewMongoTestContainer` | `NewMongoTestContainerE` |
| `NewSSHTestContainer` | `NewSSHTestContainerE` |
| `NewSSHTestContainerWithUser` | `NewSSHTestContainerWithUserE` |
| `NewFTPTestContainer` | `NewFTPTestContainerE` |
| `NewLocalstackTestContainer` | `NewLocalstackTestContainerE` |
89 changes: 43 additions & 46 deletions containers/ftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ type FTPTestContainer struct {

// NewFTPTestContainer uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax.
func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
fc, err := NewFTPTestContainerE(ctx)
require.NoError(t, err)
return fc
}

// NewFTPTestContainerE uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax.
// Returns error instead of using require.NoError, suitable for TestMain usage.
func NewFTPTestContainerE(ctx context.Context) (*FTPTestContainer, error) {
const (
defaultUser = "ftpuser"
defaultPassword = "ftppass"
Expand All @@ -38,9 +46,6 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
fixedHostControlPort = "2121"
)

// set up logging for testcontainers if the appropriate API is available
t.Logf("Setting up FTP test container")

pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort)
pasvPortRangeHost := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) // map 1:1
exposedPortsWithBinding := []string{
Expand All @@ -49,7 +54,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
}

imageName := "delfer/alpine-ftp-server:latest"
t.Logf("Using FTP server image: %s", imageName)
fmt.Printf("Creating FTP container using %s (fixed host port %s)...\n", imageName, fixedHostControlPort)

req := testcontainers.ContainerRequest{
Image: imageName,
Expand All @@ -60,57 +65,58 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
WaitingFor: wait.ForListeningPort(nat.Port("21/tcp")).WithStartupTimeout(2 * time.Minute),
}

t.Logf("creating FTP container using %s (minimal env vars, fixed host port %s)...", imageName, fixedHostControlPort)
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
// create the container instance to use its methods
ftpContainer := &FTPTestContainer{}

// error handling with detailed logging for container startup issues
if err != nil {
ftpContainer.logContainerError(ctx, t, container, err, imageName)
logContainerLogs(ctx, container)
return nil, fmt.Errorf("failed to create ftp container: %w", err)
}
t.Logf("FTP container created and started (ID: %s)", container.GetContainerID())
fmt.Printf("FTP container created and started (ID: %s)\n", container.GetContainerID())

host, err := container.Host(ctx)
require.NoError(t, err, "Failed to get container host")
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get container host: %w", err)
}

// since we requested a fixed port, construct the nat.Port struct directly
// we still call MappedPort just to ensure the container is properly exposing *something* for port 21
_, err = container.MappedPort(ctx, "21")
require.NoError(t, err, "Failed to get mapped port info for container port 21/tcp (even though fixed)")
if _, err = container.MappedPort(ctx, "21"); err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get mapped port: %w", err)
}

// construct the Port struct based on our fixed request
fixedHostNatPort, err := nat.NewPort("tcp", fixedHostControlPort)
require.NoError(t, err, "Failed to create nat.Port for fixed host port")

t.Logf("FTP container should be accessible at: %s:%s (Control Plane)", host, fixedHostControlPort)
t.Logf("FTP server using default config, passive ports %s mapped to host %s", pasvPortRangeContainer, pasvPortRangeHost)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to create nat.Port for fixed host port: %w", err)
}

time.Sleep(1 * time.Second)

fmt.Printf("FTP container accessible at: %s:%s (passive ports %s)\n", host, fixedHostControlPort, pasvPortRangeHost)

return &FTPTestContainer{
Container: container,
Host: host,
Port: fixedHostNatPort, // use the manually constructed nat.Port for the fixed host port
User: defaultUser,
Password: defaultPassword,
}
}, nil
}

// connect function (Use default EPSV enabled)
// connect establishes an FTP connection and logs in
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment was simplified but loses clarity. The original comment "connect function (Use default EPSV enabled)" provided context about the EPSV configuration. The new comment "connect establishes an FTP connection and logs in" doesn't mention this important detail about EPSV being enabled by default, which could be relevant for users troubleshooting connection issues.

Suggested change
// connect establishes an FTP connection and logs in
// connect establishes an FTP connection and logs in, using the default FTP client settings (EPSV enabled by default).

Copilot uses AI. Check for mistakes.
func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error) {
opts := []ftp.DialOption{
ftp.DialWithTimeout(30 * time.Second),
ftp.DialWithContext(ctx),
ftp.DialWithDebugOutput(os.Stdout), // keep for debugging
// *** Use default (EPSV enabled) ***
// ftp.DialWithDisabledEPSV(true),
ftp.DialWithDebugOutput(os.Stdout),
}

connStr := fc.ConnectionString() // will use the fixed host port (e.g., 2121)
connStr := fc.ConnectionString()
fmt.Printf("Attempting FTP connection to: %s (User: %s)\n", connStr, fc.User)

c, err := ftp.Dial(connStr, opts...)
Expand All @@ -123,9 +129,7 @@ func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error
fmt.Printf("Attempting FTP login with user: %s\n", fc.User)
if err := c.Login(fc.User, fc.Password); err != nil {
fmt.Printf("FTP Login Error for user %s: %v\n", fc.User, err)
if quitErr := c.Quit(); quitErr != nil {
fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
}
_ = c.Quit()
return nil, fmt.Errorf("failed to login to FTP server with user %s: %w", fc.User, err)
}
fmt.Printf("FTP Login successful for user %s\n", fc.User)
Expand Down Expand Up @@ -378,32 +382,25 @@ func splitPath(path string) []string {
return strings.Split(cleanPath, "/")
}
Copy link

Copilot AI Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed helper methods logContainerError and logContainerLogs may be useful for debugging container startup failures. While removing test-specific logging from the E-suffix variant is correct, consider whether these debugging utilities should be preserved elsewhere or if similar debugging capabilities should be available when using the E-suffix variants in TestMain contexts where troubleshooting container startup issues can be challenging.

Copilot uses AI. Check for mistakes.

// logContainerError handles container startup errors with detailed logging
func (fc *FTPTestContainer) logContainerError(_ context.Context, t *testing.T, container testcontainers.Container, err error, imageName string) {
logCtx, logCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer logCancel()

fc.logContainerLogs(logCtx, t, container)
require.NoError(t, err, "Failed to create or start FTP container %s", imageName)
}

// logContainerLogs attempts to fetch and log container logs
func (fc *FTPTestContainer) logContainerLogs(ctx context.Context, t *testing.T, container testcontainers.Container) {
// logContainerLogs attempts to fetch and print container logs for debugging startup failures
func logContainerLogs(ctx context.Context, container testcontainers.Container) {
if container == nil {
t.Logf("Container object was nil after GenericContainer failure.")
fmt.Printf("Container object was nil after GenericContainer failure.\n")
return
}

logs, logErr := container.Logs(ctx)
if logErr != nil {
t.Logf("Could not retrieve container logs after startup failure: %v", logErr)
logCtx, logCancel := context.WithTimeout(ctx, 10*time.Second)
defer logCancel()

logs, err := container.Logs(logCtx)
if err != nil {
fmt.Printf("Could not retrieve container logs after startup failure: %v\n", err)
return
}
defer logs.Close()

logBytes, _ := io.ReadAll(logs)
if closeErr := logs.Close(); closeErr != nil {
t.Logf("warning: failed to close logs reader: %v", closeErr)
if len(logBytes) > 0 {
fmt.Printf("Container logs:\n%s\n", string(logBytes))
}

t.Logf("Container logs on startup failure:\n%s", string(logBytes))
}
24 changes: 20 additions & 4 deletions containers/localstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ type LocalstackTestContainer struct {

// NewLocalstackTestContainer creates a new Localstack test container and returns a LocalstackTestContainer instance
func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTestContainer {
lc, err := NewLocalstackTestContainerE(ctx)
require.NoError(t, err)
return lc
}

// NewLocalstackTestContainerE creates a new Localstack test container and returns a LocalstackTestContainer instance.
// Returns error instead of using require.NoError, suitable for TestMain usage.
func NewLocalstackTestContainerE(ctx context.Context) (*LocalstackTestContainer, error) {
req := testcontainers.ContainerRequest{
Image: "localstack/localstack:3.0.0",
ExposedPorts: []string{"4566/tcp"},
Expand All @@ -50,19 +58,27 @@ func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTe
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
if err != nil {
return nil, fmt.Errorf("failed to create localstack container: %w", err)
}

host, err := container.Host(ctx)
require.NoError(t, err)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get container host: %w", err)
}

port, err := container.MappedPort(ctx, "4566")
require.NoError(t, err)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get mapped port: %w", err)
}

endpoint := fmt.Sprintf("http://%s:%s", host, port.Port())
return &LocalstackTestContainer{
Container: container,
Endpoint: endpoint,
}
}, nil
}

// MakeS3Connection creates a new S3 connection using the test container endpoint and returns the connection and a bucket name
Expand Down
41 changes: 34 additions & 7 deletions containers/mongo.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ type MongoTestContainer struct {

// NewMongoTestContainer creates a new MongoDB test container
func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int) *MongoTestContainer {
mc, err := NewMongoTestContainerE(ctx, mongoVersion)
require.NoError(t, err)
return mc
}

// NewMongoTestContainerE creates a new MongoDB test container.
// Returns error instead of using require.NoError, suitable for TestMain usage.
func NewMongoTestContainerE(ctx context.Context, mongoVersion int) (*MongoTestContainer, error) {
origURL := os.Getenv("MONGO_TEST")
req := testcontainers.ContainerRequest{
Image: fmt.Sprintf("mongo:%d", mongoVersion),
Expand All @@ -35,26 +43,45 @@ func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int)
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
if err != nil {
return nil, fmt.Errorf("failed to create mongo container: %w", err)
}

host, err := container.Host(ctx)
require.NoError(t, err)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get container host: %w", err)
}

port, err := container.MappedPort(ctx, "27017")
require.NoError(t, err)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get mapped port: %w", err)
}

uri := fmt.Sprintf("mongodb://%s:%s", host, port.Port())
err = os.Setenv("MONGO_TEST", uri)
require.NoError(t, err)
if err = os.Setenv("MONGO_TEST", uri); err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to set MONGO_TEST env: %w", err)
}

client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
require.NoError(t, err)
if err != nil {
if origURL != "" {
_ = os.Setenv("MONGO_TEST", origURL)
} else {
_ = os.Unsetenv("MONGO_TEST")
}
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to connect to mongo: %w", err)
}

return &MongoTestContainer{
Container: container,
URI: uri,
Client: client,
origURL: origURL,
}
}, nil
}

// Collection returns a new collection with unique name for tests
Expand Down
Loading