core/core.go
core/core.goBrowse 1970 files
2,936 tokens
12,019 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1package core2 3import (4 "encoding/json"5 "fmt"6 "maps"7 "os"8 "path/filepath"9 "slices"10 11 "github.com/charmbracelet/log"12 "github.com/railwayapp/railpack/core/app"13 c "github.com/railwayapp/railpack/core/config"14 "github.com/railwayapp/railpack/core/generate"15 "github.com/railwayapp/railpack/core/logger"16 "github.com/railwayapp/railpack/core/mise"17 "github.com/railwayapp/railpack/core/plan"18 "github.com/railwayapp/railpack/core/providers"19 "github.com/railwayapp/railpack/core/providers/procfile"20 "github.com/railwayapp/railpack/core/resolver"21 "github.com/railwayapp/railpack/internal/utils"22)23 24const (25 defaultConfigFileName = "railpack.json"26)27 28type GenerateBuildPlanOptions struct {29 RailpackVersion string30 BuildCommand string31 StartCommand string32 PreviousVersions map[string]string33 ConfigFilePath string34 ErrorMissingStartCommand bool // enabled on railway35}36 37type BuildResult struct {38 RailpackVersion string `json:"railpackVersion,omitempty"`39 Plan *plan.BuildPlan `json:"plan,omitempty"`40 ResolvedPackages map[string]*resolver.ResolvedPackage `json:"resolvedPackages,omitempty"`41 Metadata map[string]string `json:"metadata,omitempty"`42 DetectedProviders []string `json:"detectedProviders,omitempty"`43 Logs []logger.Msg `json:"logs,omitempty"`44 // always serialized so consumers of the info file can read the outcome of a failed build45 Success bool `json:"success"`46}47 48func readConfigJSON(path string, v any) error {49 data, err := os.ReadFile(path)50 if err != nil {51 return err52 }53 54 jsonBytes, err := utils.StandardizeJSON([]byte(data))55 if err != nil {56 return err57 }58 59 stringData := string(jsonBytes)60 61 if err := json.Unmarshal([]byte(stringData), v); err != nil {62 return fmt.Errorf("error reading %s as JSON: %w", path, err)63 }64 65 return nil66}67 68// generates a build plan for the app. The result is never nil: a failure to plan the69// app is reported with `Success: false` and the reason in `Logs`. A non-nil error is70// returned only for transient failures (see mise.IsTemporary), which say nothing about71// the app itself and are worth retrying.72func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) (*BuildResult, error) {73 logger := logger.NewLogger()74 75 config, err := GetConfig(app, env, options, logger)76 if err != nil {77 return failedBuildResult(logger, err)78 }79 80 ctx, err := generate.NewGenerateContext(app, env, config, logger)81 if err != nil {82 return failedBuildResult(logger, err)83 }84 85 // Set the previous versions86 if options.PreviousVersions != nil {87 for name, version := range options.PreviousVersions {88 ctx.Resolver.SetPreviousVersion(name, version)89 }90 }91 92 // Figure out what providers to use93 providerToUse, detectedProviderName := getProviders(ctx, config)94 ctx.Metadata.Set("providers", detectedProviderName)95 96 // TODO: We should indicate if we have packages specified in the config97 // so that providers can determine if they should include mise in the final image (e.g. for shell script)98 99 if providerToUse != nil {100 err = providerToUse.Plan(ctx)101 if err != nil {102 return failedBuildResult(logger, err)103 }104 }105 106 // Run the procfile provider to support apps that have a Procfile with a start command107 procfileProvider := &procfile.ProcfileProvider{}108 if _, err := procfileProvider.Plan(ctx); err != nil {109 return failedBuildResult(logger, err)110 }111 112 // before `Generate()` any commands provided by railpack.json are *not* merged into the provider-generated113 // buildPlan. This means providers can't view any of the custom structure provided by the user via a railpack.json114 buildPlan, resolvedPackages, err := ctx.Generate()115 if err != nil {116 return failedBuildResult(logger, err)117 }118 119 railpackVersion := options.RailpackVersion120 if railpackVersion == "" {121 railpackVersion = "dev"122 }123 // Bake the builder version into the runtime image for observability124 buildPlan.Deploy.Variables["RAILPACK_VERSION"] = railpackVersion125 126 if providerToUse != nil {127 providerToUse.CleansePlan(buildPlan)128 }129 130 if env != nil {131 disabledCaches, _ := env.GetConfigVariableList("DISABLE_CACHES")132 if len(disabledCaches) > 1 && slices.Contains(disabledCaches, "*") {133 logger.LogWarn("RAILPACK_DISABLE_CACHES contains `*`; all other cache keys will be ignored.")134 }135 disablePlanCaches(buildPlan, disabledCaches)136 }137 138 if !ValidatePlan(buildPlan, app, logger, &ValidatePlanOptions{139 ErrorMissingStartCommand: options.ErrorMissingStartCommand,140 ProviderToUse: providerToUse,141 }) {142 return &BuildResult{Success: false, Logs: logger.Logs}, nil143 }144 145 buildResult := &BuildResult{146 RailpackVersion: railpackVersion,147 Plan: buildPlan,148 ResolvedPackages: resolvedPackages,149 Metadata: ctx.Metadata.Properties,150 DetectedProviders: []string{detectedProviderName},151 Logs: logger.Logs,152 Success: true,153 }154 155 return buildResult, nil156}157 158// Removes disabled cache mounts from the generated plan.159func disablePlanCaches(buildPlan *plan.BuildPlan, disabledCaches []string) {160 if len(disabledCaches) == 0 {161 return162 }163 164 // A wildcard removes every cache definition and all step references.165 if slices.Contains(disabledCaches, "*") {166 clear(buildPlan.Caches)167 for i := range buildPlan.Steps {168 buildPlan.Steps[i].Caches = nil169 }170 return171 }172 173 // Remove named caches from their definitions and from every step that uses them.174 disabled := make(map[string]struct{}, len(disabledCaches))175 for _, cache := range disabledCaches {176 disabled[cache] = struct{}{}177 delete(buildPlan.Caches, cache)178 }179 180 for i := range buildPlan.Steps {181 caches := slices.DeleteFunc(buildPlan.Steps[i].Caches, func(cache string) bool {182 _, ok := disabled[cache]183 return ok184 })185 if len(caches) == 0 {186 caches = nil187 }188 buildPlan.Steps[i].Caches = caches189 }190}191 192// records a planning failure in the build result. Transient failures are also returned193// as an error so callers can tell them apart from a deterministic failure of the app.194func failedBuildResult(logger *logger.Logger, err error) (*BuildResult, error) {195 logger.LogError("%s", err.Error())196 197 result := &BuildResult{Success: false, Logs: logger.Logs}198 if mise.IsTemporary(err) {199 return result, err200 }201 202 return result, nil203}204 205// GetConfig merges the options, environment, and file config into a single config206func GetConfig(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions, logger *logger.Logger) (*c.Config, error) {207 optionsConfig := GenerateConfigFromOptions(options)208 209 envConfig := GenerateConfigFromEnvironment(env)210 211 fileConfig, err := GenerateConfigFromFile(app, env, options, logger)212 if err != nil {213 return nil, err214 }215 216 mergedConfig := c.Merge(optionsConfig, envConfig, fileConfig)217 // Environment-provided secrets must remain available when file configuration replaces slice values.218 mergedConfig.Secrets = utils.RemoveDuplicates(slices.Concat(envConfig.Secrets, mergedConfig.Secrets))219 220 return mergedConfig, nil221}222 223func GenerateConfigFromFile(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions, logger *logger.Logger) (*c.Config, error) {224 config := c.EmptyConfig()225 226 configFileName := defaultConfigFileName227 if options.ConfigFilePath != "" {228 configFileName = options.ConfigFilePath229 }230 231 if envConfigFileName, _ := env.GetConfigVariable("CONFIG_FILE"); envConfigFileName != "" {232 configFileName = envConfigFileName233 }234 235 // always assume config file path is relative to the app source directory236 // https://github.com/railwayapp/railpack/pull/226237 absConfigFileName := filepath.Join(app.Source, configFileName)238 239 if _, err := os.Stat(absConfigFileName); err != nil && os.IsNotExist(err) {240 // if a specific path was specified, we should indicate that it was not found and hard fail241 if configFileName != defaultConfigFileName {242 return nil, fmt.Errorf("config file %q not found", absConfigFileName)243 }244 245 return config, nil246 }247 248 // if a JSON file was provided, we should hard fail if we cannot parse it249 if err := readConfigJSON(absConfigFileName, config); err != nil {250 logger.LogWarn("Failed to read config file `%s`\nUse the following schema to validate your config file: %s\n", configFileName, c.SchemaUrl)251 return nil, err252 }253 254 logger.LogInfo("Using config file `%s`", configFileName)255 logger.LogWarn("The config file format is not yet finalized and subject to change.")256 257 return config, nil258}259 260func GenerateConfigFromEnvironment(env *app.Environment) *c.Config {261 config := c.EmptyConfig()262 263 if env == nil {264 return config265 }266 267 if installCmdVar, _ := env.GetConfigVariable("INSTALL_CMD"); installCmdVar != "" {268 installStep := config.GetOrCreateStep("install")269 installStep.Commands = []plan.Command{270 plan.NewCopyCommand("."),271 plan.NewExecShellCommand(installCmdVar, plan.ExecOptions{CustomName: installCmdVar}),272 }273 }274 275 if buildCmdVar, _ := env.GetConfigVariable("BUILD_CMD"); buildCmdVar != "" {276 buildStep := config.GetOrCreateStep("build")277 buildStep.Commands = []plan.Command{278 plan.NewCopyCommand("."),279 plan.NewExecShellCommand(buildCmdVar, plan.ExecOptions{CustomName: buildCmdVar}),280 }281 }282 283 if startCmdVar, _ := env.GetConfigVariable("START_CMD"); startCmdVar != "" {284 config.Deploy.StartCmd = startCmdVar285 }286 287 if packages, _ := env.GetConfigVariableList("PACKAGES"); len(packages) > 0 {288 config.Packages = utils.ParsePackageWithVersion(packages)289 }290 291 if aptPackages, _ := env.GetConfigVariableList("BUILD_APT_PACKAGES"); len(aptPackages) > 0 {292 config.BuildAptPackages = aptPackages293 }294 295 if aptPackages, _ := env.GetConfigVariableList("DEPLOY_APT_PACKAGES"); len(aptPackages) > 0 {296 config.Deploy.AptPackages = aptPackages297 }298 299 config.Secrets = append(config.Secrets, slices.Sorted(maps.Keys(env.Variables))...)300 301 return config302}303 304// generates a config from the CLI options305func GenerateConfigFromOptions(options *GenerateBuildPlanOptions) *c.Config {306 config := c.EmptyConfig()307 308 if options == nil {309 return config310 }311 312 if options.BuildCommand != "" {313 buildStep := config.GetOrCreateStep("build")314 buildStep.Commands = []plan.Command{315 plan.NewCopyCommand("."),316 plan.NewExecShellCommand(options.BuildCommand, plan.ExecOptions{CustomName: options.BuildCommand}),317 }318 }319 320 if options.StartCommand != "" {321 config.Deploy.StartCmd = options.StartCommand322 }323 324 return config325}326 327func getProviders(ctx *generate.GenerateContext, config *c.Config) (providers.Provider, string) {328 allProviders := providers.GetLanguageProviders()329 330 var providerToUse providers.Provider331 var detectedProvider string332 333 // Even if there are providers manually specified, we want to detect to see what type of app this is334 for _, provider := range allProviders {335 matched, err := provider.Detect(ctx)336 if err != nil {337 log.Warnf("Failed to detect provider `%s`: %s", provider.Name(), err.Error())338 continue339 }340 341 if matched {342 detectedProvider = provider.Name()343 344 // If there are no providers manually specified in the config,345 if config.Provider == nil {346 if err := provider.Initialize(ctx); err != nil {347 ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", provider.Name(), err.Error())348 continue349 }350 351 ctx.Logger.LogInfo("Detected %s", utils.CapitalizeFirst(provider.Name()))352 353 providerToUse = provider354 }355 356 break357 }358 }359 360 if config.Provider != nil {361 provider := providers.GetProvider(*config.Provider)362 363 if provider == nil {364 ctx.Logger.LogWarn("Provider `%s` not found", *config.Provider)365 return providerToUse, detectedProvider366 }367 368 if err := provider.Initialize(ctx); err != nil {369 ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", *config.Provider, err.Error())370 return providerToUse, detectedProvider371 }372 373 ctx.Logger.LogInfo("Using provider %s from config", utils.CapitalizeFirst(*config.Provider))374 providerToUse = provider375 }376 377 return providerToUse, detectedProvider378}379