fix: use containerd platform parsing to fix platform parsing (#277)

* docs: inline llm instructions into mise.toml

* fix: use containerd platform parsing and remove custom implementation

* refactor: use a helper to define the default platform when none is defined

* test: platform unit tests

* test: platform integration test

* refactor: simplify integration test assertions

* test: inline tests.json into platform integration tests

* refactor: move platform parsing inside BuildWithBuildkitClient

* test: merge platform tests into general integration tests

* test: update snapshot

* ci: add qemu to integration tests
This commit is contained in:
Michael Bianco
2025-09-26 08:47:40 -06:00
committed by GitHub
parent 2bf1167b61
commit 299cd74c52
13 changed files with 228 additions and 118 deletions
+4
View File
@@ -64,6 +64,10 @@ jobs:
- name: Install dependencies
run: go mod download
# required for integration tests that use multiple platforms
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Run test for ${{ matrix.example }}
env:
# without the GITHUB_TOKEN, mise will 403 us
+6 -10
View File
@@ -1,7 +1,7 @@
# What is Railpack
Zero-config application builder that automatically analyzes your code and turns
it into a container image. It's the successor to Nixpacks, built on BuildKit
it into a container image. It's built on BuildKit
with support for Node, Python, Go, PHP, and more.
# Architecture
@@ -15,13 +15,6 @@ with support for Node, Python, Go, PHP, and more.
- **Providers**: Language-specific modules that detect project types (e.g. Node
detects package.json) and generate appropriate build steps
# Bash commands
- `mise run build` - Build the CLI binary (`./bin/railpack`), use this instead of `go build`
- `mise run check` - Run linting, formatting, and static analysis
- `mise run test` - Run unit tests
- `mise run test-integration` - Runs *all* integration tests. Never run this. Instead, only run the test that you are currently working on, e.g. `mise run test-integration -- -run "TestExamplesIntegration/config-file"`
# Code style
- Follow Go conventions and existing patterns in the codebase
@@ -34,10 +27,13 @@ with support for Node, Python, Go, PHP, and more.
# Workflow
- Do not use `go` directly. Instead, inspect @mise.toml and use `mise run <task>` to run various dev lifecycle commands.
- Be sure to run `mise run check` when you're done making code changes
- Don't run tests manually unless instructed to do so
- Run unit tests and a couple of relevant integration tests to verify your changes
- Don't run tests manually using `go test` unless instructed to do so
- If tests are failing that are unrelated to your changes, let me know and stop working.
- Use the `cli` mise task to test your changes on a specific example project, i.e. `mise run cli -- --verbose build --show-plan examples/node-vite-react-router-spa/`
- Never `git commit`
- Do not run any write operations with `git`
- Do not use `bin/railpack` instead use `mise run cli` (which is the development build of `railpack`)
# File Conventions
+7 -5
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/charmbracelet/log"
"github.com/containerd/platforms"
"github.com/moby/buildkit/client"
_ "github.com/moby/buildkit/client/connhelper/dockercontainer"
_ "github.com/moby/buildkit/client/connhelper/nerdctlcontainer"
@@ -49,7 +50,7 @@ type BuildWithBuildkitClientOptions struct {
ProgressMode string
SecretsHash string
Secrets map[string]string
Platform BuildPlatform
Platform string
ImportCache string
ExportCache string
CacheKey string
@@ -85,9 +86,10 @@ func BuildWithBuildkitClient(appDir string, plan *plan.BuildPlan, opts BuildWith
return errors.New(buildkitInfoError)
}
buildPlatform := opts.Platform
if (buildPlatform == BuildPlatform{}) {
buildPlatform = DetermineBuildPlatformFromHost()
// Parse the platform string using our helper function
buildPlatform, err := ParsePlatformWithDefaults(opts.Platform)
if err != nil {
return fmt.Errorf("failed to parse platform '%s': %w", opts.Platform, err)
}
llbState, image, err := ConvertPlanToLLB(plan, ConvertPlanOptions{
@@ -175,7 +177,7 @@ func BuildWithBuildkitClient(appDir string, plan *plan.BuildPlan, opts BuildWith
return fmt.Errorf("error creating FS: %w", err)
}
log.Debugf("Building image for %s with BuildKit %s", buildPlatform.String(), info.BuildkitVersion.Version)
log.Debugf("Building image for %s with BuildKit %s", platforms.Format(buildPlatform), info.BuildkitVersion.Version)
secretsMap := make(map[string][]byte)
for k, v := range opts.Secrets {
+2 -2
View File
@@ -14,7 +14,7 @@ import (
)
type ConvertPlanOptions struct {
BuildPlatform BuildPlatform
BuildPlatform specs.Platform
// Hash of all the secrets values that can be used to invalidate the layer cache when a secret changes
SecretsHash string
@@ -34,7 +34,7 @@ const (
)
func ConvertPlanToLLB(plan *p.BuildPlan, opts ConvertPlanOptions) (*llb.State, *Image, error) {
platform := opts.BuildPlatform.ToPlatform()
platform := opts.BuildPlatform
localState := llb.Local("context",
llb.SharedKeyHint("local"),
+10 -18
View File
@@ -13,6 +13,7 @@ import (
"github.com/moby/buildkit/frontend/gateway/client"
gw "github.com/moby/buildkit/frontend/gateway/grpcclient"
"github.com/moby/buildkit/util/appcontext"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/railwayapp/railpack/core/plan"
)
@@ -120,31 +121,22 @@ func readRailpackPlan(ctx context.Context, c client.Client) (*plan.BuildPlan, er
return plan, nil
}
// validatePlatform checks if the platform is supported and returns the corresponding BuildPlatform
func validatePlatform(opts map[string]string) (BuildPlatform, error) {
// validatePlatform checks if the platform is supported and returns the corresponding specs.Platform
func validatePlatform(opts map[string]string) (specs.Platform, error) {
platformStr := opts["platform"]
if platformStr == "" {
// Default to host platform if none specified
return DetermineBuildPlatformFromHost(), nil
}
// Error if multiple platforms are specified
if strings.Contains(platformStr, ",") {
return BuildPlatform{}, fmt.Errorf("multiple platforms are not supported, got: %s", platformStr)
return specs.Platform{}, fmt.Errorf("multiple platforms are not supported, got: %s", platformStr)
}
// Match against supported platforms
switch platformStr {
case PlatformLinuxAMD64.String():
return PlatformLinuxAMD64, nil
case PlatformLinuxARM64.String():
return PlatformLinuxARM64, nil
default:
return BuildPlatform{}, fmt.Errorf("unsupported platform: %s. Must be one of: %s, %s",
platformStr,
PlatformLinuxAMD64.String(),
PlatformLinuxARM64.String())
// Parse the platform using our helper function
platform, err := ParsePlatformWithDefaults(platformStr)
if err != nil {
return specs.Platform{}, fmt.Errorf("invalid platform format: %s. Must be one of: linux/amd64, linux/arm64, etc", platformStr)
}
return platform, nil
}
// Read a file from the build context
+28 -55
View File
@@ -1,66 +1,39 @@
package buildkit
import (
"fmt"
"runtime"
"github.com/containerd/platforms"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
type BuildPlatform struct {
OS string
Architecture string
Variant string
}
var (
PlatformLinuxAMD64 = BuildPlatform{
OS: "linux",
Architecture: "amd64",
}
PlatformLinuxARM64 = BuildPlatform{
OS: "linux",
Architecture: "arm64",
Variant: "v8",
}
)
func DetermineBuildPlatformFromHost() BuildPlatform {
if runtime.GOARCH == "arm64" {
return PlatformLinuxARM64
}
return PlatformLinuxAMD64
}
func ParsePlatform(platformStr string) (BuildPlatform, error) {
// ParsePlatformWithDefaults parses a platform string and returns the corresponding specs.Platform.
// If the input is empty, it defaults to a Linux platform that matches the host architecture.
//
// This function handles the common case where we need to map host platforms to container platforms.
// We cannot use platforms.DefaultSpec() directly because it returns the host platform
// (e.g., darwin/arm64/v8 on macOS), but container base images only support Linux platforms.
// Instead, we map the host architecture to the corresponding Linux container platform
// to provide optimal performance while ensuring compatibility with container runtimes.
//
// Examples:
// - "" -> linux/amd64 (on Intel hosts) or linux/arm64/v8 (on ARM hosts)
// - "linux/amd64" -> linux/amd64
// - "linux/arm64" -> linux/arm64
// - "linux/arm64/v8" -> linux/arm64/v8
func ParsePlatformWithDefaults(platformStr string) (specs.Platform, error) {
if platformStr == "" {
return DetermineBuildPlatformFromHost(), nil
// Default to Linux platform for container builds
// We cannot use platforms.DefaultSpec() directly because it returns the host platform
// (e.g., darwin/arm64/v8 on macOS), but container base images only support Linux platforms.
// Instead, we map the host architecture to the corresponding Linux container platform
// to provide optimal performance while ensuring compatibility with container runtimes.
hostPlatform := platforms.DefaultSpec()
if hostPlatform.Architecture == "arm64" {
return specs.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"}, nil
} else {
return specs.Platform{OS: "linux", Architecture: "amd64"}, nil
}
}
platform, err := platforms.Parse(platformStr)
if err != nil {
return BuildPlatform{}, fmt.Errorf("invalid platform format: %s. Must be one of: linux/amd64, linux/arm64, etc", platformStr)
}
return BuildPlatform{
OS: platform.OS,
Architecture: platform.Architecture,
Variant: platform.Variant,
}, nil
}
func (p BuildPlatform) String() string {
if p.Variant != "" {
return fmt.Sprintf("%s/%s/%s", p.OS, p.Architecture, p.Variant)
}
return fmt.Sprintf("%s/%s", p.OS, p.Architecture)
}
func (p BuildPlatform) ToPlatform() specs.Platform {
return specs.Platform{
OS: p.OS,
Architecture: p.Architecture,
Variant: p.Variant,
}
// Parse the user-specified platform string
return platforms.Parse(platformStr)
}
+94 -20
View File
@@ -2,70 +2,144 @@ package buildkit
import (
"testing"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
func TestParsePlatform(t *testing.T) {
func TestValidatePlatform(t *testing.T) {
tests := []struct {
name string
input string
expected BuildPlatform
expected specs.Platform
wantErr bool
}{
{
name: "empty string returns host platform",
name: "empty string returns Linux platform",
input: "",
expected: func() BuildPlatform {
return DetermineBuildPlatformFromHost()
// When no platform is specified, we default to a Linux platform that matches
// the host architecture. This ensures container compatibility while preserving
// performance characteristics of the host system.
expected: func() specs.Platform {
platform, _ := ParsePlatformWithDefaults("")
return platform
}(),
wantErr: false,
},
{
name: "linux/arm64/v8",
input: "linux/arm64/v8",
expected: BuildPlatform{
expected: specs.Platform{
OS: "linux",
Architecture: "arm64",
Variant: "v8",
},
wantErr: false,
},
{
name: "linux/amd64",
input: "linux/amd64",
expected: specs.Platform{
OS: "linux",
Architecture: "amd64",
},
wantErr: false,
},
{
name: "invalid input",
input: "invalid",
wantErr: true,
},
{
name: "multiple platforms not supported",
input: "linux/amd64,linux/arm64",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePlatform(tt.input)
got, err := ParsePlatformWithDefaults(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("ParsePlatform() expected error but got none")
t.Errorf("ParsePlatformWithDefaults() expected error but got none")
}
return
}
if err != nil {
t.Errorf("ParsePlatform() error = %v", err)
t.Errorf("ParsePlatformWithDefaults() error = %v", err)
return
}
if got != tt.expected {
t.Errorf("ParsePlatform() = %v, want %v", got, tt.expected)
if got.OS != tt.expected.OS || got.Architecture != tt.expected.Architecture || got.Variant != tt.expected.Variant {
t.Errorf("ParsePlatformWithDefaults() = %v, want %v", got, tt.expected)
}
})
}
}
func TestBuildPlatformString(t *testing.T) {
platform := BuildPlatform{
OS: "linux",
Architecture: "arm64",
Variant: "v8",
func TestValidatePlatformWithMultiplePlatforms(t *testing.T) {
tests := []struct {
name string
input string
expected specs.Platform
wantErr bool
}{
{
name: "empty string returns Linux platform",
input: "",
expected: func() specs.Platform {
platform, _ := ParsePlatformWithDefaults("")
return platform
}(),
wantErr: false,
},
{
name: "linux/arm64/v8",
input: "linux/arm64/v8",
expected: specs.Platform{
OS: "linux",
Architecture: "arm64",
Variant: "v8",
},
wantErr: false,
},
{
name: "linux/amd64",
input: "linux/amd64",
expected: specs.Platform{
OS: "linux",
Architecture: "amd64",
},
wantErr: false,
},
{
name: "invalid input",
input: "invalid",
wantErr: true,
},
{
name: "multiple platforms not supported",
input: "linux/amd64,linux/arm64",
wantErr: true,
},
}
expected := "linux/arm64/v8"
got := platform.String()
if got != expected {
t.Errorf("BuildPlatform.String() = %v, want %v", got, expected)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := map[string]string{"platform": tt.input}
got, err := validatePlatform(opts)
if tt.wantErr {
if err == nil {
t.Errorf("validatePlatform() expected error but got none")
}
return
}
if err != nil {
t.Errorf("validatePlatform() error = %v", err)
return
}
if got.OS != tt.expected.OS || got.Architecture != tt.expected.Architecture || got.Variant != tt.expected.Variant {
t.Errorf("validatePlatform() = %v, want %v", got, tt.expected)
}
})
}
}
+2 -6
View File
@@ -86,11 +86,7 @@ var BuildCommand = &cli.Command{
secretsHash := getSecretsHash(env)
platform, err := buildkit.ParsePlatform(cmd.String("platform"))
if err != nil {
return cli.Exit(err, 1)
}
platformStr := cmd.String("platform")
err = buildkit.BuildWithBuildkitClient(app.Source, buildResult.Plan, buildkit.BuildWithBuildkitClientOptions{
ImageName: cmd.String("name"),
DumpLLB: cmd.Bool("dump-llb"),
@@ -99,7 +95,7 @@ var BuildCommand = &cli.Command{
CacheKey: cmd.String("cache-key"),
SecretsHash: secretsHash,
Secrets: env.Variables,
Platform: platform,
Platform: platformStr,
GitHubToken: os.Getenv("GITHUB_TOKEN"),
})
if err != nil {
@@ -0,0 +1,40 @@
{
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
"inputs": [
{
"include": [
"."
],
"step": "build"
}
],
"startCommand": "sh start.sh"
},
"steps": [
{
"commands": [
{
"cmd": "chmod +x start.sh"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
# Output the container architecture
uname -m
+14
View File
@@ -0,0 +1,14 @@
[
{
"expectedOutput": "x86_64",
"platform": "linux/amd64"
},
{
"expectedOutput": "aarch64",
"platform": "linux/arm64"
},
{
"expectedOutput": "aarch64",
"platform": "linux/arm64/v8"
}
]
+10 -2
View File
@@ -25,6 +25,7 @@ var buildkitCacheExport = flag.String("buildkit-cache-export", "", "BuildKit cac
type TestCase struct {
ExpectedOutput string `json:"expectedOutput"`
Platform string `json:"platform"`
Envs map[string]string `json:"envs"`
ConfigFilePath string `json:"configFile"`
JustBuild bool `json:"justBuild"`
@@ -102,6 +103,7 @@ func TestExamplesIntegration(t *testing.T) {
if err := buildkit.BuildWithBuildkitClient(examplePath, buildResult.Plan, buildkit.BuildWithBuildkitClientOptions{
ImageName: imageName,
Platform: testCase.Platform,
ImportCache: *buildkitCacheImport,
ExportCache: *buildkitCacheExport,
Secrets: testCase.Envs,
@@ -117,7 +119,7 @@ func TestExamplesIntegration(t *testing.T) {
return
}
if err := runContainerWithTimeout(t, imageName, testCase.ExpectedOutput, testCase.Envs); err != nil {
if err := runContainerWithTimeout(t, imageName, testCase.ExpectedOutput, testCase.Envs, testCase.Platform); err != nil {
t.Fatal(err)
}
})
@@ -131,7 +133,7 @@ func cmdDoneChan(cmd *exec.Cmd) chan error {
return ch
}
func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, envs map[string]string) error {
func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, envs map[string]string, platformStr string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -140,6 +142,12 @@ func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, env
// 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)
}
for key, value := range envs {
args = append(args, "-e", fmt.Sprintf("%s=%s", key, value))
}
+7
View File
@@ -12,21 +12,28 @@ run = [
[tasks.cli]
run = "go run cmd/cli/main.go"
# build the CLI binary (`./bin/railpack`)
[tasks.build]
run = "go build -o bin/railpack cmd/cli/main.go"
[tasks.clean]
run = "rm -rf bin dist docs/dist"
# run unit tests
[tasks.test]
run = "go test -short ./..."
# Runs *all* integration tests. It is extremely slow, do not run this locally.
# Instead, only run the test that you are currently working on:
# `mise run test-integration -- -run "TestExamplesIntegration/config-file"`
[tasks.test-integration]
run = "go test -v ./integration_tests"
# update all test snapshots
[tasks.test-update-snapshots]
run = "UPDATE_SNAPS=true go test -short ./..."
# linting, formatting, and static analysis
[tasks.check]
run = """
go vet ./...