Files
pack/cli/build.go
T
Ed GaniukovandGitHub 01ecfada39 fix: exit 75 for transient failures (#690)
Every failure exited 1, so callers could only retry blindly:
deterministic failures (no provider detected, missing start command)
were run and logged three times by builderv3 before giving up.

Transient failures are now typed as mise.TemporaryError and map to exit
75 (EX_TEMPFAIL): transport errors, 429, 408, 5xx, and truncated
bodies. A 404 for a pinned mise version and a malformed archive stay
deterministic, since retrying them cannot succeed. The mise download
also had no response status check at all, so a 5xx body was written to
disk and later failed as "binary not found in archive".

Carrying that across the process boundary meant giving
GenerateBuildPlan an error return: a mise install failure was
previously swallowed into BuildResult{Success: false} and exited 1
along with every app-level failure. The error is non-nil only for
transient failures, so !Success stays on exit 1.

The download gets an http.Client timeout, since http.DefaultClient has
none and a stalled connection would hang the build forever. It
deliberately does not retry in-process: the caller already retries the
whole command, and retrying here would multiply that and delay the
signal.

Also writes --info-out even when the build result is unsuccessful, so
callers can read {"success": false, "logs": [...]} instead of scraping
stdout.
2026-08-12 14:46:23 +02:00

154 lines
4.2 KiB
Go

// entrypoint to the `build` subcommand
// Primarily bundles CLI options into the structure that `BuildWithBuildkitClient` expects
package cli
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/railwayapp/railpack/buildkit"
"github.com/railwayapp/railpack/core"
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/plan"
"github.com/urfave/cli/v3"
)
var BuildCommand = &cli.Command{
Name: "build",
Aliases: []string{"b"},
Usage: "build an image with BuildKit",
ArgsUsage: "DIRECTORY",
EnableShellCompletion: true,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "name of the image to build",
},
&cli.StringFlag{
Name: "output",
Usage: "output the final filesystem to a local directory",
},
&cli.StringFlag{
Name: "platform",
Usage: "platform to build for (e.g. linux/amd64, linux/arm64)",
},
&cli.StringFlag{
Name: "progress",
Usage: "buildkit progress output mode. Values: auto, plain, tty",
Value: "auto",
},
&cli.BoolFlag{
Name: "show-plan",
Usage: "Show the build plan before building. This is useful for development and debugging.",
Value: false,
},
&cli.StringFlag{
Name: "cache-key",
Usage: "Unique id to prefix to cache keys",
},
&cli.StringSliceFlag{
Name: "cache-from",
Usage: "External cache sources",
},
&cli.StringSliceFlag{
Name: "cache-to",
Usage: "Cache export destinations",
},
&cli.BoolFlag{
Name: "no-cache",
Usage: "Do not use cache when building",
Value: false,
},
&cli.BoolFlag{
Name: "dump-llb",
Hidden: true,
Value: false,
},
}, commonPlanFlags()...),
Action: func(ctx context.Context, cmd *cli.Command) error {
buildResult, app, env, err := GenerateBuildResultForCommand(cmd)
if err != nil {
return cli.Exit(err, exitCodeForError(err))
}
if !cmd.Bool("dump-llb") {
core.PrettyPrintBuildResult(buildResult, core.PrintOptions{Version: Version})
}
if !buildResult.Success {
os.Exit(ExitCodeFailure)
return nil
}
if cmd.Bool("show-plan") && !cmd.Bool("dump-llb") {
planMap, err := addSchemaToPlanMap(buildResult.Plan)
if err != nil {
return cli.Exit(err, ExitCodeFailure)
}
serializedPlan, err := json.MarshalIndent(planMap, "", " ")
if err != nil {
return cli.Exit(err, ExitCodeFailure)
}
core.PrettyPrintSectionHeader(os.Stdout, "Generated railpack-plan.json")
core.PrettyPrintJSON(os.Stdout, serializedPlan)
}
err = validateSecrets(buildResult.Plan, env)
if err != nil {
return cli.Exit(err, ExitCodeFailure)
}
secretsHash := getSecretsHash(env)
platformStr := cmd.String("platform")
err = buildkit.BuildWithBuildkitClient(app.Source, buildResult.Plan, buildkit.BuildWithBuildkitClientOptions{
ImageName: cmd.String("name"),
DumpLLB: cmd.Bool("dump-llb"),
OutputDir: cmd.String("output"),
ProgressMode: cmd.String("progress"),
CacheKey: cmd.String("cache-key"),
// StringSlice to support multiple cache-from / cache-to entries, same shape as docker buildx
ImportCache: cmd.StringSlice("cache-from"),
ExportCache: cmd.StringSlice("cache-to"),
SecretsHash: secretsHash,
Secrets: env.Variables,
Platform: platformStr,
GitHubToken: os.Getenv("GITHUB_TOKEN"),
NoCache: cmd.Bool("no-cache"),
})
if err != nil {
return cli.Exit(err, ExitCodeFailure)
}
return nil
},
}
// make sure all secrets referenced in the build plan are present in the environment
func validateSecrets(plan *plan.BuildPlan, env *app.Environment) error {
for _, secret := range plan.Secrets {
if _, ok := env.Variables[secret]; !ok {
return fmt.Errorf("missing environment variable: %s. Please set using --env %s=%s", secret, secret, "...")
}
}
return nil
}
// generate a hash all of build secrets to invalidate all caches when any secret changes
func getSecretsHash(env *app.Environment) string {
var secretsValue strings.Builder
for _, v := range env.Variables {
secretsValue.WriteString(v)
}
hasher := sha256.New()
hasher.Write([]byte(secretsValue.String()))
return fmt.Sprintf("%x", hasher.Sum(nil))
}