mirror of
https://github.com/hanzoai/pack.git
synced 2026-09-30 23:43:13 +00:00
* fix: respect cache backend type for registry cache import/export * docs: update local development build instructions - Update local image building to use the provided project task. - Document the workflow for building with a local registry. - Add examples for configuring buildctl with caching and debugging flags. Generated-by: aiautocommit * refactor(buildkit): clean up code comments and formatting - Remove redundant comments and improve internal documentation. - Fix minor formatting inconsistencies in code blocks and comment descriptions. Generated-by: aiautocommit * feat: add ParseBool utility to handle common boolean string variants Generated-by: aiautocommit * feat: integrate local registry and buildkit configuration - Add buildkitd configuration to enable HTTP/insecure access to the local registry. - Introduce a local registry service container on port 7890. - Update the buildkit setup to mount the new registry configuration. - Add tasks for building the frontend image and running project builds using the local registry and buildctl. Generated-by: aiautocommit * docs: update CLAUDE.md with architecture and style guidance Generated-by: aiautocommit * feat: add support for multiple cache-from and cache-to build flags - Allow users to configure external cache sources and destinations via the `build` subcommand. - Update cache parsing logic to support multiple entries matching the Docker buildx CLI format. - Remove hardcoded default cache type to provide better control over BuildKit cache configurations. Generated-by: aiautocommit * test: add integration tests for cache-import parsing Generated-by: aiautocommit * refactor(buildkit): extract cache entry parsing logic - Centralize cache configuration logic by moving loop-based parsing into a reusable `cacheEntriesFromFlags` helper. - Improve code maintainability and readability by reducing duplication in `BuildWithBuildkitClient`. - Add comprehensive unit tests for cache entry processing, including edge cases for flag parsing and empty inputs. Generated-by: aiautocommit * fix(test): remove unused buildkit cache flags from integration tests * Remove unused flag variables that were meant for remote BuildKit caching. * Ensure integration tests avoid remote cache (e.g., type=gha) to prevent exceeding GitHub Actions cache limits and excessive eviction. Generated-by: aiautocommit * test(buildkit): consolidate cache import tests - Move TestParseCacheImports from its own file to frontend_test.go to consolidate related buildkit frontend tests. Generated-by: aiautocommit * style: fix indentation in build test helper Generated-by: aiautocommit * docs: document support for BuildKit cache backends Generated-by: aiautocommit * docs(developing-locally): add information on Docker, BuildKit, and caching - Add missing setup prerequisites. - Include debugging and configuration guidance for BuildKit/Docker. - Clarify cache export and import implementation expectations in Railpack. Generated-by: aiautocommit * docs: clarify cache flag parsing and pnpm version logic Generated-by: aiautocommit --------- Co-authored-by: Matt Van Horn <[email protected]> Co-authored-by: Michael Bianco <[email protected]> Co-authored-by: Hanzo Dev <[email protected]>
412 lines
13 KiB
Go
412 lines
13 KiB
Go
package integration_tests
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/railwayapp/railpack/buildkit"
|
|
"github.com/railwayapp/railpack/core"
|
|
"github.com/railwayapp/railpack/core/app"
|
|
"github.com/railwayapp/railpack/internal/utils"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Integration builds intentionally do not use BuildKit remote cache (e.g. type=gha).
|
|
// Example images would overrun GitHub Actions' 10GB cache limit and thrash eviction:
|
|
// https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#usage-limits-and-eviction-policy
|
|
|
|
type StringOrArray []string
|
|
|
|
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
|
|
var str string
|
|
if err := json.Unmarshal(data, &str); err == nil {
|
|
*s = []string{str}
|
|
return nil
|
|
}
|
|
|
|
var arr []string
|
|
if err := json.Unmarshal(data, &arr); err != nil {
|
|
return err
|
|
}
|
|
|
|
*s = arr
|
|
return nil
|
|
}
|
|
|
|
type TestCase struct {
|
|
Platform string `json:"platform"`
|
|
// can be a single string, or an array of strings for multiple expected outputs
|
|
// matches against the entire output of the container if it cannot be found in a single line
|
|
ExpectedOutput StringOrArray `json:"expectedOutput"`
|
|
Envs map[string]string `json:"envs"`
|
|
ConfigFilePath string `json:"configFile"`
|
|
JustBuild bool `json:"justBuild"`
|
|
ShouldFail bool `json:"shouldFail"`
|
|
HTTPCheck *HTTPCheck `json:"httpCheck"`
|
|
StderrAllowed bool `json:"stderrAllowed"`
|
|
// architectures to skip, e.g. ["arm64"]. Matches against runtime.GOARCH.
|
|
SkipArch []string `json:"skipArch"`
|
|
}
|
|
|
|
func TestExamplesIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
examplesDir := filepath.Join(filepath.Dir(wd), "examples")
|
|
entries, err := os.ReadDir(examplesDir)
|
|
require.NoError(t, err)
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// read in the test assertions in each example and generate a unique test case for each entry in the JSON array
|
|
testConfigPath := filepath.Join(examplesDir, entry.Name(), "test.json")
|
|
if _, err := os.Stat(testConfigPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
testConfigBytes, err := os.ReadFile(testConfigPath)
|
|
require.NoError(t, err)
|
|
|
|
// allow json5/hujson in the test.json file
|
|
testConfigBytes, err = utils.StandardizeJSON(testConfigBytes)
|
|
require.NoError(t, err)
|
|
|
|
// extract test.json from the example directory, and fail if unexpected fields are present
|
|
var testCases []TestCase
|
|
decoder := json.NewDecoder(strings.NewReader(string(testConfigBytes)))
|
|
decoder.DisallowUnknownFields()
|
|
err = decoder.Decode(&testCases)
|
|
require.NoError(t, err)
|
|
|
|
// Validate test case configuration
|
|
for i, testCase := range testCases {
|
|
// Check if both httpCheck and expectedOutput are specified in the same test case
|
|
if testCase.HTTPCheck != nil && len(testCase.ExpectedOutput) > 0 {
|
|
t.Fatalf("%s case-%d: cannot have both httpCheck and expectedOutput in the same test case", entry.Name(), i)
|
|
}
|
|
|
|
// Check if justBuild is used alongside other test cases
|
|
if testCase.JustBuild && len(testCases) > 1 {
|
|
t.Fatalf("%s: justBuild can only be used alone (no other test cases allowed in the same file)", entry.Name())
|
|
}
|
|
}
|
|
|
|
examplePath := filepath.Join(examplesDir, entry.Name())
|
|
|
|
// each entry in the tests.json array is an individual test case which runs it's own container
|
|
for i, testCase := range testCases {
|
|
|
|
testName := fmt.Sprintf("%s/case-%d", entry.Name(), i)
|
|
t.Run(testName, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if slices.Contains(testCase.SkipArch, runtime.GOARCH) {
|
|
t.Skipf("skipping %s on %s", entry.Name(), runtime.GOARCH)
|
|
}
|
|
|
|
fmt.Printf("\033[32mRunning: examples/%s\033[0m\n", entry.Name())
|
|
|
|
userApp, err := app.NewApp(examplePath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create app: %v", err)
|
|
}
|
|
|
|
env := app.NewEnvironment(&testCase.Envs)
|
|
buildResult := core.GenerateBuildPlan(userApp, env, &core.GenerateBuildPlanOptions{
|
|
ConfigFilePath: testCase.ConfigFilePath,
|
|
})
|
|
|
|
// Handle case where we expect the build to fail
|
|
if testCase.ShouldFail {
|
|
if buildResult.Success {
|
|
t.Fatalf("expected build to fail, but it succeeded")
|
|
}
|
|
// Test passes - build failed as expected
|
|
return
|
|
}
|
|
|
|
if !buildResult.Success {
|
|
t.Fatalf("failed to generate build plan: %v", buildResult.Logs)
|
|
}
|
|
|
|
if buildResult == nil {
|
|
t.Fatal("build result is nil")
|
|
}
|
|
|
|
// strictly for debugging when attempting to reproduce and compare a build locally
|
|
core.PrettyPrintBuildResult(buildResult)
|
|
|
|
// generate a completely random, but readable, image name for the example project
|
|
imageName := uniqueContainerName(
|
|
strings.ToLower(strings.ReplaceAll(testName, "/", "-")),
|
|
strings.ToLower(uuid.New().String()))
|
|
|
|
if err := buildkit.BuildWithBuildkitClient(examplePath, buildResult.Plan, buildkit.BuildWithBuildkitClientOptions{
|
|
ImageName: imageName,
|
|
Platform: testCase.Platform,
|
|
Secrets: testCase.Envs,
|
|
CacheKey: imageName,
|
|
// Pass through GITHUB_TOKEN if it exists, this avoids mise timeouts during build
|
|
// this can easily occur since we run all integration tests in parallel via GHA
|
|
GitHubToken: os.Getenv("GITHUB_TOKEN"),
|
|
}); err != nil {
|
|
t.Fatalf("failed to build image: %v", err)
|
|
}
|
|
|
|
// only need to calculate the size of the image *once*, not for every test case
|
|
// this is for the image benchmarking system
|
|
if i == 0 {
|
|
if sizeBytes, err := getImageSize(imageName); err == nil {
|
|
t.Logf("image size for %s: %s", entry.Name(), formatBytes(sizeBytes))
|
|
folderSizes, err := getFolderSizes(imageName)
|
|
if err != nil {
|
|
t.Logf("warning: failed to get folder sizes: %v", err)
|
|
}
|
|
if err := writeImageSize(examplePath, entry.Name(), sizeBytes, folderSizes); err != nil {
|
|
t.Logf("warning: failed to write size.json: %v", err)
|
|
}
|
|
} else {
|
|
t.Logf("warning: failed to get image size: %v", err)
|
|
}
|
|
}
|
|
|
|
if testCase.JustBuild {
|
|
return
|
|
}
|
|
|
|
// Start docker-compose services for this test case if they exist
|
|
composeConfig, err := detectAndStartCompose(examplePath, t)
|
|
if err != nil {
|
|
t.Fatalf("failed to start compose for %s: %v", entry.Name(), err)
|
|
}
|
|
|
|
if composeConfig != nil {
|
|
t.Cleanup(func() {
|
|
if err := stopAndCleanupCompose(composeConfig, t); err != nil {
|
|
t.Errorf("failed to cleanup docker-compose for %s: %v", entry.Name(), err)
|
|
}
|
|
})
|
|
}
|
|
|
|
networkName := ""
|
|
if composeConfig != nil {
|
|
networkName = composeConfig.NetworkName
|
|
}
|
|
|
|
if testCase.HTTPCheck != nil {
|
|
if err := runContainerWithHTTPCheck(t, imageName, testCase.Envs, testCase.HTTPCheck, networkName); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := runContainerWithTimeout(t, imageName, testCase.ExpectedOutput, testCase.Envs, testCase.Platform, networkName, testCase.StderrAllowed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func getImageSize(imageName string) (int64, error) {
|
|
out, err := exec.Command("docker", "image", "inspect", imageName, "--format", "{{.Size}}").Output()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
|
}
|
|
|
|
// returns the byte size of each root-level directory in the
|
|
// image. This is strictly informational for developers to understand what is
|
|
// contributing to image size
|
|
func getFolderSizes(imageName string) (map[string]int64, error) {
|
|
out, err := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", imageName, "-c", "du -sb /* 2>/dev/null || true").Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
skip := map[string]bool{"/proc": true, "/sys": true, "/dev": true}
|
|
sizes := map[string]int64{}
|
|
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "\t", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
path := strings.TrimSpace(parts[1])
|
|
if skip[path] {
|
|
continue
|
|
}
|
|
size, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sizes[path] = size
|
|
}
|
|
return sizes, nil
|
|
}
|
|
|
|
func formatBytes(bytes int64) string {
|
|
const mb = 1024 * 1024
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/mb)
|
|
}
|
|
|
|
// Writes image size metadata to size.json in the example directory so CI can
|
|
// collect and track image sizes over time via benchmark-action.
|
|
func writeImageSize(examplePath string, name string, sizeBytes int64, folderSizes map[string]int64) error {
|
|
data := map[string]any{
|
|
// not required, but makes it easier to identify which example a size.json was tied to
|
|
"name": name,
|
|
"size": sizeBytes,
|
|
"sizeHuman": formatBytes(sizeBytes),
|
|
"folders": folderSizes,
|
|
}
|
|
out, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(examplePath, "size.json"), out, 0644)
|
|
}
|
|
|
|
// wait until cmd subprocess exits
|
|
func cmdDoneChan(cmd *exec.Cmd) chan error {
|
|
ch := make(chan error, 1)
|
|
go func() { ch <- cmd.Wait() }()
|
|
return ch
|
|
}
|
|
|
|
// this is run for each test case generated by test.json
|
|
// If networkName is provided, the container will be connected to that network.
|
|
func runContainerWithTimeout(t *testing.T, imageName string, expectedOutputs []string, envs map[string]string, platformStr string, networkName string, stderrAllowed bool) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
// Generate a unique container name so we can reference it later for cleanup
|
|
containerName := uniqueContainerName(uuid.New().String())
|
|
|
|
// Build docker run command with environment variables
|
|
args := []string{"run", "--rm", "--name", containerName}
|
|
|
|
// Add platform specification if provided
|
|
if platformStr != "" {
|
|
args = append(args, "--platform", platformStr)
|
|
}
|
|
|
|
// Add network if provided
|
|
if networkName != "" {
|
|
args = append(args, "--network", networkName)
|
|
}
|
|
|
|
for key, value := range envs {
|
|
args = append(args, "-e", fmt.Sprintf("%s=%s", key, value))
|
|
}
|
|
args = append(args, imageName)
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start container: %v", err)
|
|
}
|
|
|
|
// Ensure cleanup on function exit
|
|
defer func() {
|
|
// Stop the container if it's still running
|
|
stopCmd := exec.Command("docker", "stop", containerName)
|
|
_ = stopCmd.Run()
|
|
// Remove the container if it still exists
|
|
rmCmd := exec.Command("docker", "rm", "-f", containerName)
|
|
_ = rmCmd.Run()
|
|
}()
|
|
|
|
// read container output and look for expected output
|
|
var output, stdErrOutput strings.Builder
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
foundOutputs := make([]bool, len(expectedOutputs))
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
output.WriteString(line + "\n")
|
|
|
|
// Check if this line contains any of the expected outputs
|
|
for i, expected := range expectedOutputs {
|
|
if !foundOutputs[i] && strings.Contains(line, expected) {
|
|
foundOutputs[i] = true
|
|
}
|
|
}
|
|
|
|
// Check if all expected outputs have been found
|
|
if !slices.Contains(foundOutputs, false) {
|
|
done <- nil
|
|
return
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
done <- fmt.Errorf("error reading stdout: %v", err)
|
|
return
|
|
}
|
|
done <- fmt.Errorf("container stdout output:\n%s\nStderr output:\n%s", output.String(), stdErrOutput.String())
|
|
}()
|
|
|
|
go func() {
|
|
scanner := bufio.NewScanner(stderr)
|
|
for scanner.Scan() {
|
|
stdErrOutput.WriteString(scanner.Text() + "\n")
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("container timed out after 2 minutes")
|
|
case err := <-done:
|
|
if err != nil {
|
|
return fmt.Errorf("%v\ncontainer output did not contain expected string:\n%v", err, expectedOutputs)
|
|
}
|
|
// if err == nil, then we found the expected output
|
|
// check that stderr is empty (unless explicitly allowed)
|
|
if !stderrAllowed {
|
|
stderr := stdErrOutput.String()
|
|
if stderr != "" {
|
|
return fmt.Errorf("expected stderr to be empty, but got:\n%s", stderr)
|
|
}
|
|
}
|
|
return nil
|
|
case err := <-cmdDoneChan(cmd):
|
|
if err != nil && !strings.Contains(err.Error(), "signal: killed") {
|
|
return fmt.Errorf("container failed: %v", err)
|
|
}
|
|
|
|
return fmt.Errorf("container output did not contain expected string:\n%v", expectedOutputs)
|
|
}
|
|
}
|