Files
pack/core/core.go
T
2025-03-06 17:35:49 -05:00

272 lines
8.4 KiB
Go

package core
import (
"strings"
"github.com/charmbracelet/log"
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/config"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/logger"
"github.com/railwayapp/railpack/core/plan"
"github.com/railwayapp/railpack/core/providers"
"github.com/railwayapp/railpack/core/providers/procfile"
"github.com/railwayapp/railpack/core/resolver"
"github.com/railwayapp/railpack/core/utils"
)
const (
defaultConfigFileName = "railpack.json"
)
type GenerateBuildPlanOptions struct {
RailpackVersion string
BuildCommand string
StartCommand string
PreviousVersions map[string]string
ConfigFilePath string
ErrorMissingStartCommand bool
}
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"`
Success bool `json:"success,omitempty"`
}
func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) *BuildResult {
logger := logger.NewLogger()
// Get the full user config based on file config, env config, and options
config, err := GetConfig(app, env, options, logger)
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
}
ctx, err := generate.NewGenerateContext(app, env, config, logger)
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
}
// Set the preivous 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 {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
}
}
// 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 {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
}
buildPlan, resolvedPackages, err := ctx.Generate()
if err != nil {
logger.LogError("%s", err.Error())
return &BuildResult{Success: false, Logs: logger.Logs}
}
if !ValidatePlan(buildPlan, app, logger, &ValidatePlanOptions{
ErrorMissingStartCommand: options.ErrorMissingStartCommand,
ProviderToUse: providerToUse,
}) {
return &BuildResult{Success: false, Logs: logger.Logs}
}
buildResult := &BuildResult{
RailpackVersion: options.RailpackVersion,
Plan: buildPlan,
ResolvedPackages: resolvedPackages,
Metadata: ctx.Metadata.Properties,
DetectedProviders: []string{detectedProviderName},
Logs: logger.Logs,
Success: true,
}
return buildResult
}
// 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) (*config.Config, error) {
optionsConfig := GenerateConfigFromOptions(options)
envConfig := GenerateConfigFromEnvironment(app, env)
fileConfig, err := GenerateConfigFromFile(app, env, options, logger)
if err != nil {
return nil, err
}
mergedConfig := config.Merge(optionsConfig, envConfig, fileConfig)
return mergedConfig, nil
}
// GenerateConfigFromFile generates a config from the config file
func GenerateConfigFromFile(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions, logger *logger.Logger) (*config.Config, error) {
configFileName := defaultConfigFileName
if options.ConfigFilePath != "" {
configFileName = options.ConfigFilePath
}
if envConfigFileName, _ := env.GetConfigVariable("CONFIG_FILE"); envConfigFileName != "" {
configFileName = envConfigFileName
}
if !app.HasMatch(configFileName) {
if configFileName != defaultConfigFileName {
logger.LogWarn("Config file `%s` not found", configFileName)
}
return config.EmptyConfig(), nil
}
config := config.EmptyConfig()
if err := app.ReadJSON(configFileName, config); err != nil {
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
}
// GenerateConfigFromEnvironment generates a config from the environment
func GenerateConfigFromEnvironment(app *app.App, env *app.Environment) *config.Config {
config := config.EmptyConfig()
if env == nil {
return config
}
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 envPackages, _ := env.GetConfigVariable("PACKAGES"); envPackages != "" {
config.Packages = utils.ParseVersions(strings.Split(envPackages, " "))
}
if envAptPackages, _ := env.GetConfigVariable("BUILD_APT_PACKAGES"); envAptPackages != "" {
config.BuildAptPackages = strings.Split(envAptPackages, " ")
}
if envAptPackages, _ := env.GetConfigVariable("DEPLOY_APT_PACKAGES"); envAptPackages != "" {
config.Deploy.AptPackages = strings.Split(envAptPackages, " ")
}
for name := range env.Variables {
config.Secrets = append(config.Secrets, name)
}
return config
}
// GenerateConfigFromOptions generates a config from the CLI options
func GenerateConfigFromOptions(options *GenerateBuildPlanOptions) *config.Config {
config := config.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 *config.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
}