Files
pack/core/core.go
T
zeekayandHanzo Dev 5a8a970b16 chore(pack): drop Railway branding from our own docs, comments, and examples
Rewrites our own comments, help text, and example READMEs that named
Railway or linked railway.app so they describe what they do instead.
Retargets the changelog generator and RELEASE.md's Actions/release
links at this repo. Leaves upstream issue links, the MIT copyright,
and the builder/runtime/frontend image references untouched -- those
still point at ghcr.io/railwayapp because we don't publish our own
copies of those images yet.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 02:05:25 -07:00

337 lines
10 KiB
Go

package core
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"github.com/charmbracelet/log"
"github.com/hanzoai/pack/core/app"
c "github.com/hanzoai/pack/core/config"
"github.com/hanzoai/pack/core/generate"
"github.com/hanzoai/pack/core/logger"
"github.com/hanzoai/pack/core/mise"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/providers"
"github.com/hanzoai/pack/core/providers/procfile"
"github.com/hanzoai/pack/core/resolver"
"github.com/hanzoai/pack/internal/utils"
)
const (
defaultConfigFileName = "railpack.json"
)
type GenerateBuildPlanOptions struct {
RailpackVersion string
BuildCommand string
StartCommand string
PreviousVersions map[string]string
ConfigFilePath string
ErrorMissingStartCommand bool // when true, a missing start command fails the build instead of warning
}
type BuildResult struct {
RailpackVersion string `json:"railpackVersion,omitempty"`
Plan *plan.BuildPlan `json:"plan,omitempty"`
ResolvedPackages map[string]*resolver.ResolvedPackage `json:"resolvedPackages,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
DetectedProviders []string `json:"detectedProviders,omitempty"`
Logs []logger.Msg `json:"logs,omitempty"`
// always serialized so consumers of the info file can read the outcome of a failed build
Success bool `json:"success"`
}
func readConfigJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
jsonBytes, err := utils.StandardizeJSON([]byte(data))
if err != nil {
return err
}
stringData := string(jsonBytes)
if err := json.Unmarshal([]byte(stringData), v); err != nil {
return fmt.Errorf("error reading %s as JSON: %w", path, err)
}
return nil
}
// generates a build plan for the app. The result is never nil: a failure to plan the
// app is reported with `Success: false` and the reason in `Logs`. A non-nil error is
// returned only for transient failures (see mise.IsTemporary), which say nothing about
// the app itself and are worth retrying.
func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) (*BuildResult, error) {
logger := logger.NewLogger()
config, err := GetConfig(app, env, options, logger)
if err != nil {
return failedBuildResult(logger, err)
}
ctx, err := generate.NewGenerateContext(app, env, config, logger)
if err != nil {
return failedBuildResult(logger, err)
}
// Set the previous versions
if options.PreviousVersions != nil {
for name, version := range options.PreviousVersions {
ctx.Resolver.SetPreviousVersion(name, version)
}
}
// Figure out what providers to use
providerToUse, detectedProviderName := getProviders(ctx, config)
ctx.Metadata.Set("providers", detectedProviderName)
// TODO: We should indicate if we have packages specified in the config
// so that providers can determine if they should include mise in the final image (e.g. for shell script)
if providerToUse != nil {
err = providerToUse.Plan(ctx)
if err != nil {
return failedBuildResult(logger, err)
}
}
// Run the procfile provider to support apps that have a Procfile with a start command
procfileProvider := &procfile.ProcfileProvider{}
if _, err := procfileProvider.Plan(ctx); err != nil {
return failedBuildResult(logger, err)
}
// before `Generate()` any commands provided by railpack.json are *not* merged into the provider-generated
// buildPlan. This means providers can't view any of the custom structure provided by the user via a railpack.json
buildPlan, resolvedPackages, err := ctx.Generate()
if err != nil {
return failedBuildResult(logger, err)
}
railpackVersion := options.RailpackVersion
if railpackVersion == "" {
railpackVersion = "dev"
}
// Bake the builder version into the runtime image for observability
buildPlan.Deploy.Variables["RAILPACK_VERSION"] = railpackVersion
if providerToUse != nil {
providerToUse.CleansePlan(buildPlan)
}
if !ValidatePlan(buildPlan, app, logger, &ValidatePlanOptions{
ErrorMissingStartCommand: options.ErrorMissingStartCommand,
ProviderToUse: providerToUse,
}) {
return &BuildResult{Success: false, Logs: logger.Logs}, nil
}
buildResult := &BuildResult{
RailpackVersion: railpackVersion,
Plan: buildPlan,
ResolvedPackages: resolvedPackages,
Metadata: ctx.Metadata.Properties,
DetectedProviders: []string{detectedProviderName},
Logs: logger.Logs,
Success: true,
}
return buildResult, nil
}
// records a planning failure in the build result. Transient failures are also returned
// as an error so callers can tell them apart from a deterministic failure of the app.
func failedBuildResult(logger *logger.Logger, err error) (*BuildResult, error) {
logger.LogError("%s", err.Error())
result := &BuildResult{Success: false, Logs: logger.Logs}
if mise.IsTemporary(err) {
return result, err
}
return result, nil
}
// GetConfig merges the options, environment, and file config into a single config
func GetConfig(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions, logger *logger.Logger) (*c.Config, error) {
optionsConfig := GenerateConfigFromOptions(options)
envConfig := GenerateConfigFromEnvironment(env)
fileConfig, err := GenerateConfigFromFile(app, env, options, logger)
if err != nil {
return nil, err
}
mergedConfig := c.Merge(optionsConfig, envConfig, fileConfig)
// Environment-provided secrets must remain available when file configuration replaces slice values.
mergedConfig.Secrets = utils.RemoveDuplicates(slices.Concat(envConfig.Secrets, mergedConfig.Secrets))
return mergedConfig, nil
}
func GenerateConfigFromFile(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions, logger *logger.Logger) (*c.Config, error) {
config := c.EmptyConfig()
configFileName := defaultConfigFileName
if options.ConfigFilePath != "" {
configFileName = options.ConfigFilePath
}
if envConfigFileName, _ := env.GetConfigVariable("CONFIG_FILE"); envConfigFileName != "" {
configFileName = envConfigFileName
}
// always assume config file path is relative to the app source directory
// https://github.com/hanzoai/pack/pull/226
absConfigFileName := filepath.Join(app.Source, configFileName)
if _, err := os.Stat(absConfigFileName); err != nil && os.IsNotExist(err) {
// if a specific path was specified, we should indicate that it was not found and hard fail
if configFileName != defaultConfigFileName {
return nil, fmt.Errorf("config file %q not found", absConfigFileName)
}
return config, nil
}
// if a JSON file was provided, we should hard fail if we cannot parse it
if err := readConfigJSON(absConfigFileName, config); err != nil {
logger.LogWarn("Failed to read config file `%s`\nUse the following schema to validate your config file: %s\n", configFileName, c.SchemaUrl)
return nil, err
}
logger.LogInfo("Using config file `%s`", configFileName)
logger.LogWarn("The config file format is not yet finalized and subject to change.")
return config, nil
}
func GenerateConfigFromEnvironment(env *app.Environment) *c.Config {
config := c.EmptyConfig()
if env == nil {
return config
}
if installCmdVar, _ := env.GetConfigVariable("INSTALL_CMD"); installCmdVar != "" {
installStep := config.GetOrCreateStep("install")
installStep.Commands = []plan.Command{
plan.NewCopyCommand("."),
plan.NewExecShellCommand(installCmdVar, plan.ExecOptions{CustomName: installCmdVar}),
}
}
if buildCmdVar, _ := env.GetConfigVariable("BUILD_CMD"); buildCmdVar != "" {
buildStep := config.GetOrCreateStep("build")
buildStep.Commands = []plan.Command{
plan.NewCopyCommand("."),
plan.NewExecShellCommand(buildCmdVar, plan.ExecOptions{CustomName: buildCmdVar}),
}
}
if startCmdVar, _ := env.GetConfigVariable("START_CMD"); startCmdVar != "" {
config.Deploy.StartCmd = startCmdVar
}
if packages, _ := env.GetConfigVariableList("PACKAGES"); len(packages) > 0 {
config.Packages = utils.ParsePackageWithVersion(packages)
}
if aptPackages, _ := env.GetConfigVariableList("BUILD_APT_PACKAGES"); len(aptPackages) > 0 {
config.BuildAptPackages = aptPackages
}
if aptPackages, _ := env.GetConfigVariableList("DEPLOY_APT_PACKAGES"); len(aptPackages) > 0 {
config.Deploy.AptPackages = aptPackages
}
config.Secrets = append(config.Secrets, slices.Sorted(maps.Keys(env.Variables))...)
return config
}
// generates a config from the CLI options
func GenerateConfigFromOptions(options *GenerateBuildPlanOptions) *c.Config {
config := c.EmptyConfig()
if options == nil {
return config
}
if options.BuildCommand != "" {
buildStep := config.GetOrCreateStep("build")
buildStep.Commands = []plan.Command{
plan.NewCopyCommand("."),
plan.NewExecShellCommand(options.BuildCommand, plan.ExecOptions{CustomName: options.BuildCommand}),
}
}
if options.StartCommand != "" {
config.Deploy.StartCmd = options.StartCommand
}
return config
}
func getProviders(ctx *generate.GenerateContext, config *c.Config) (providers.Provider, string) {
allProviders := providers.GetLanguageProviders()
var providerToUse providers.Provider
var detectedProvider string
// Even if there are providers manually specified, we want to detect to see what type of app this is
for _, provider := range allProviders {
matched, err := provider.Detect(ctx)
if err != nil {
log.Warnf("Failed to detect provider `%s`: %s", provider.Name(), err.Error())
continue
}
if matched {
detectedProvider = provider.Name()
// If there are no providers manually specified in the config,
if config.Provider == nil {
if err := provider.Initialize(ctx); err != nil {
ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", provider.Name(), err.Error())
continue
}
ctx.Logger.LogInfo("Detected %s", utils.CapitalizeFirst(provider.Name()))
providerToUse = provider
}
break
}
}
if config.Provider != nil {
provider := providers.GetProvider(*config.Provider)
if provider == nil {
ctx.Logger.LogWarn("Provider `%s` not found", *config.Provider)
return providerToUse, detectedProvider
}
if err := provider.Initialize(ctx); err != nil {
ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", *config.Provider, err.Error())
return providerToUse, detectedProvider
}
ctx.Logger.LogInfo("Using provider %s from config", utils.CapitalizeFirst(*config.Provider))
providerToUse = provider
}
return providerToUse, detectedProvider
}