Files
pack/core/core.go
T
zeekay 7844a60aff refactor(pack): modernize to github.com/hanzoai/pack and remove upstream branding
- Migrate module path to github.com/hanzoai/pack across all Go packages
- Remove legacy upstream docs and competitor brand references
- Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
2026-09-04 00:40:42 -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 // enabled on railway
}
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
}