Files
Michael BiancoandGitHub 3fee1b57cd refactor: update to go 1.21+ syntax and idioms (#524)
* refactor: update to go 1.21+ syntax and idioms

- Replace interface{} with any throughout the codebase.
- Use strings.SplitSeq and strings.CutPrefix for more efficient string processing.
- Utilize slices.Contains for cleaner slice membership checks.
- Use strings.Builder for efficient string concatenation.
- Adopt modern loop syntax (for range) for iteration.
- Remove redundant omitempty tags in struct definitions.

Generated-by: aiautocommit

* refactor: linting fix

* build: standardize commit messages for automation tasks

- Update commit messages in the upgrade task to follow conventional commit standards.
- Ensure consistency across automated fix, formatting, and snapshot update processes.

Generated-by: aiautocommit

* refactor: update commit messages in mise-upgrade task

Generated-by: aiautocommit
2026-03-23 08:07:06 -06:00

232 lines
6.8 KiB
Go

package integration_tests
import (
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
const (
// Grace period for servers to bind after initial log lines.
containerStartGracePeriod = 2 * time.Second
// Total timeout for the HTTP check to succeed. Instead of specifying the number of retries, we use a total timeout.
httpCheckTimeout = 35 * time.Second
// Time between HTTP check retries.
httpCheckInterval = 300 * time.Millisecond
// Timeout for a single HTTP GET request.
httpCheckClientTimeout = 3 * time.Second
// Network name suffix when docker-compose creates the network
composeNetworkSuffix = "_default"
)
// ComposeConfig holds information about docker-compose services for an example
type ComposeConfig struct {
ProjectName string
ExamplePath string
NetworkName string
DockerComposePath string
}
// detectAndStartCompose checks if a docker-compose.yml exists in the example directory
// and starts the services with a unique network name. Returns nil if no compose file exists or is empty.
func detectAndStartCompose(examplePath string, t interface {
Logf(format string, args ...any)
}) (*ComposeConfig, error) {
composeFile := filepath.Join(examplePath, "docker-compose.yml")
// Check if docker-compose.yml exists
fileInfo, err := os.Stat(composeFile)
if os.IsNotExist(err) {
return nil, nil
}
// Skip empty files (e.g., test files that verify dockerignore works)
if fileInfo.Size() == 0 {
return nil, nil
}
// Generate a unique project name and network name
projectName := fmt.Sprintf("railpack-test-%s", strings.ToLower(uuid.New().String()))
networkName := projectName + composeNetworkSuffix
config := &ComposeConfig{
ProjectName: projectName,
ExamplePath: examplePath,
NetworkName: networkName,
DockerComposePath: composeFile,
}
// Start docker-compose services and wait for them to be ready
cmd := exec.Command("docker", "compose",
"-f", composeFile,
"--project-name", projectName,
"up", "-d", "--wait")
out, err := cmd.CombinedOutput()
if err != nil {
_ = stopAndCleanupCompose(config, t)
return nil, fmt.Errorf("failed to start docker-compose services: %v: %s", err, string(out))
}
t.Logf("Started docker-compose services with network: %s", networkName)
return config, nil
}
// stopAndCleanupCompose stops and removes docker-compose services
func stopAndCleanupCompose(config *ComposeConfig, logger interface {
Logf(format string, args ...any)
}) error {
if config == nil {
return nil
}
cmd := exec.Command("docker", "compose",
"-f", config.DockerComposePath,
"--project-name", config.ProjectName,
"down", "--volumes")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to stop docker-compose services: %v: %s", err, string(out))
}
return nil
}
// HTTPCheck defines an HTTP endpoint check for a container
// Path defaults to /, Expected defaults to 200.
type HTTPCheck struct {
InternalPort int `json:"internalPort"`
Path string `json:"path"`
Expected int `json:"expected"`
ExpectedOutput StringOrArray `json:"expectedOutput"`
}
// runContainerWithHTTPCheck starts a container from the given image,
// maps the internalPort to a random host port, and checks the given path returns the expected status code.
// If networkName is provided, the container will be connected to that network.
func runContainerWithHTTPCheck(t *testing.T, imageName string, envs map[string]string, hc *HTTPCheck, networkName string) error {
if networkName != "" {
t.Logf("Using network: %s", networkName)
}
if hc.Path == "" {
hc.Path = "/"
}
if hc.Expected == 0 {
hc.Expected = 200
}
if hc.InternalPort == 0 {
return fmt.Errorf("httpCheck.internalPort must be specified")
}
hostPort, err := pickFreePort()
if err != nil {
return fmt.Errorf("allocate host port: %w", err)
}
// at this point, the container has already been built, so GITHUB_TOKEN should not be needed
containerName := uniqueContainerName(uuid.New().String())
// Run detached without --rm so we can fetch logs even if it exits.
// Cleanup is handled explicitly in the deferred stopContainer call below.
args := []string{"run", "-d", "--name", containerName, "-p", fmt.Sprintf("127.0.0.1:%d:%d", hostPort, hc.InternalPort)}
if networkName != "" {
args = append(args, "--network", networkName)
}
for k, v := range envs { // test-provided envs
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
}
args = append(args, imageName)
if out, runErr := exec.Command("docker", args...).CombinedOutput(); runErr != nil {
return fmt.Errorf("docker run failed: %v: %s", runErr, string(out))
}
t.Logf("Started container %s from image %s (mapped host %d -> %d)", containerName, imageName, hostPort, hc.InternalPort)
defer func() {
logs := fetchContainerLogs(containerName)
t.Logf("Container logs:\n%s", logs)
stopContainer(containerName)
}()
// Small grace period: some servers bind after initial log lines
time.Sleep(containerStartGracePeriod)
url := fmt.Sprintf("http://127.0.0.1:%d%s", hostPort, hc.Path)
body, err := waitForHTTPStatus(url, hc.Expected)
if err != nil {
return fmt.Errorf("http check failed: %w", err)
}
for _, expected := range hc.ExpectedOutput {
if !strings.Contains(body, expected) {
return fmt.Errorf("response body did not contain expected string: %q\nBody:\n%s", expected, body)
}
}
t.Logf("HTTP check passed (%s => %d)", hc.Path, hc.Expected)
return nil
}
func uniqueContainerName(parts ...string) string {
return fmt.Sprintf("railpack-test-%s", strings.Join(parts, "-"))
}
func pickFreePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
func waitForHTTPStatus(url string, expected int) (string, error) {
deadline := time.Now().Add(httpCheckTimeout)
client := &http.Client{Timeout: httpCheckClientTimeout}
var lastErr error
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
lastErr = fmt.Errorf("failed to read response body: %w", readErr)
time.Sleep(httpCheckInterval)
continue
}
if resp.StatusCode == expected {
return string(body), nil
}
lastErr = fmt.Errorf("http status %d != %d", resp.StatusCode, expected)
} else {
lastErr = err
}
time.Sleep(httpCheckInterval)
}
if lastErr == nil {
// if we haven't returned, and the HTTP client failed, then we've timed out
lastErr = fmt.Errorf("timeout waiting for %s", url)
}
return "", lastErr
}
func fetchContainerLogs(name string) string {
out, _ := exec.Command("docker", "logs", name).CombinedOutput()
return string(out)
}
func stopContainer(name string) { _ = exec.Command("docker", "rm", "-f", name).Run() }