core/generate/context.go
core/generate/context.goBrowse 1970 files
2,857 tokens
11,242 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1package generate2 3import (4 "fmt"5 "maps"6 "slices"7 "sort"8 "strings"9 10 "github.com/charmbracelet/log"11 a "github.com/railwayapp/railpack/core/app"12 "github.com/railwayapp/railpack/core/config"13 "github.com/railwayapp/railpack/core/logger"14 "github.com/railwayapp/railpack/core/mise"15 "github.com/railwayapp/railpack/core/plan"16 "github.com/railwayapp/railpack/core/resolver"17 "github.com/railwayapp/railpack/internal/utils"18)19 20type BuildStepOptions struct {21 ResolvedPackages map[string]*resolver.ResolvedPackage22 Caches *CacheContext23}24 25type StepBuilder interface {26 Name() string27 Build(p *plan.BuildPlan, options *BuildStepOptions) error28}29 30type GenerateContext struct {31 App *a.App32 Env *a.Environment33 Config *config.Config34 dockerignoreCtx *plan.DockerignoreContext35 36 BaseImage string37 Steps []StepBuilder38 Deploy *DeployBuilder39 40 Caches *CacheContext41 Secrets []string42 43 SubContexts []string44 45 Metadata *Metadata46 Resolver *resolver.Resolver47 MiseStepBuilder *MiseStepBuilder48 49 Logger *logger.Logger50}51 52type Command interface {53 IsSpread() bool54}55 56type CommandWrapper struct {57 Command plan.Command58}59 60// is a user-provided command entry a "spread" command?61func (c CommandWrapper) IsSpread() bool {62 if execCmd, ok := c.Command.(plan.ExecCommand); ok {63 return execCmd.Cmd == plan.ShellCommandString("...") || execCmd.Cmd == "..."64 }65 return false66}67 68func NewGenerateContext(app *a.App, env *a.Environment, config *config.Config, logger *logger.Logger) (*GenerateContext, error) {69 resolver, err := resolver.NewResolver(mise.InstallDir)70 if err != nil {71 return nil, err72 }73 74 dockerignoreCtx, err := plan.NewDockerignoreContext(app)75 if err != nil {76 return nil, fmt.Errorf("failed to parse .dockerignore: %w", err)77 }78 79 if dockerignoreCtx.HasFile {80 logger.LogInfo("Found .dockerignore file, applying filters")81 log.Debugf("Dockerignore patterns: %v", dockerignoreCtx.Excludes)82 }83 84 ctx := &GenerateContext{85 App: app,86 Env: env,87 Config: config,88 Steps: make([]StepBuilder, 0),89 Deploy: NewDeployBuilder(),90 Caches: NewCacheContext(),91 Secrets: []string{},92 Metadata: NewMetadata(),93 Resolver: resolver,94 Logger: logger,95 dockerignoreCtx: dockerignoreCtx,96 }97 98 ctx.applyPackagesFromConfig()99 100 if dockerignoreCtx.HasFile {101 ctx.Metadata.SetBool("dockerIgnore", true)102 }103 104 return ctx, nil105}106 107func (c *GenerateContext) GetMiseStepBuilder() *MiseStepBuilder {108 if c.MiseStepBuilder == nil {109 c.MiseStepBuilder = c.newMiseStepBuilder()110 }111 return c.MiseStepBuilder112}113 114func (c *GenerateContext) EnterSubContext(subContext string) *GenerateContext {115 c.SubContexts = append(c.SubContexts, subContext)116 return c117}118 119func (c *GenerateContext) ExitSubContext() *GenerateContext {120 c.SubContexts = c.SubContexts[:len(c.SubContexts)-1]121 return c122}123 124func (c *GenerateContext) GetStepName(name string) string {125 subContextNames := strings.Join(c.SubContexts, ":")126 if subContextNames != "" {127 return name + ":" + subContextNames128 }129 return name130}131 132func (c *GenerateContext) GetStepByName(name string) *StepBuilder {133 for _, step := range c.Steps {134 if step.Name() == name {135 return &step136 }137 }138 return nil139}140 141func (c *GenerateContext) ResolvePackages() (map[string]*resolver.ResolvedPackage, error) {142 return c.Resolver.ResolvePackages()143}144 145// Generate a build plan from the context146func (c *GenerateContext) Generate() (*plan.BuildPlan, map[string]*resolver.ResolvedPackage, error) {147 c.applyConfig()148 149 // Resolve all package versions into a fully qualified and valid version150 resolvedPackages, err := c.ResolvePackages()151 if err != nil {152 return nil, nil, err153 }154 155 buildPlan := plan.NewBuildPlan()156 157 // Merge exclude patterns from .dockerignore and railpack.json158 excludePatterns := []string{}159 excludePatterns = append(excludePatterns, c.dockerignoreCtx.Excludes...)160 excludePatterns = append(excludePatterns, c.Config.Exclude...)161 if len(excludePatterns) > 0 {162 buildPlan.Exclude = excludePatterns163 }164 165 buildStepOptions := &BuildStepOptions{166 ResolvedPackages: resolvedPackages,167 Caches: c.Caches,168 }169 170 for _, stepBuilder := range c.Steps {171 err := stepBuilder.Build(buildPlan, buildStepOptions)172 173 if err != nil {174 return nil, nil, fmt.Errorf("failed to build step: %w", err)175 }176 }177 178 buildPlan.Caches = c.Caches.Caches179 buildPlan.Secrets = utils.RemoveDuplicates(c.Secrets)180 c.Deploy.Build(buildPlan, buildStepOptions)181 182 buildPlan.Normalize()183 184 return buildPlan, resolvedPackages, nil185}186 187func (o *BuildStepOptions) NewAptInstallCommand(pkgs []string) plan.Command {188 pkgs = utils.RemoveDuplicates(pkgs)189 sort.Strings(pkgs)190 191 // sh -c is required because && is a shell operator that needs a shell to interpret it192 return plan.NewExecCommand("sh -c 'apt-get update && apt-get install -y "+strings.Join(pkgs, " ")+"'", plan.ExecOptions{193 CustomName: "install apt packages: " + strings.Join(pkgs, " "),194 })195}196 197func (c *GenerateContext) applyPackagesFromConfig() {198 miseStep := c.GetMiseStepBuilder()199 200 // railpack.json supports defining custom packages, if we find them we seed the mise builder versions with those user-specified values201 // other more specific version definitions (such as package.json, ENV vars, etc) will take precedence over these202 for _, pkg := range slices.Sorted(maps.Keys(c.Config.Packages)) {203 version := c.Config.Packages[pkg]204 pkgRef := miseStep.Default(pkg, version)205 // `custom config` and not `railpack.json` is used since the source of the custom config could be a CLI flag or custom config file206 miseStep.Version(pkgRef, version, "custom config")207 }208}209 210func (c *GenerateContext) applyConfig() {211 c.applyPackagesFromConfig()212 c.applyBuildAptPackages()213 214 // Apply the cache config to the context215 maps.Copy(c.Caches.Caches, c.Config.Caches)216 c.Secrets = plan.SpreadStrings(c.Config.Secrets, c.Secrets)217 218 // Update deploy from config219 if c.Config.Deploy != nil {220 if c.Config.Deploy.Base != nil && !c.Config.Deploy.Base.IsEmpty() {221 c.Deploy.Base = *c.Config.Deploy.Base222 }223 224 if c.Config.Deploy.StartCmd != "" {225 c.Deploy.StartCmd = c.Config.Deploy.StartCmd226 }227 228 c.applyDeployAptPackages()229 c.Deploy.DeployInputs = plan.Spread(c.Config.Deploy.Inputs, c.Deploy.DeployInputs)230 c.Deploy.Paths = plan.SpreadStrings(c.Config.Deploy.Paths, c.Deploy.Paths)231 maps.Copy(c.Deploy.Variables, c.Config.Deploy.Variables)232 }233 234 // A spread retains generated deploy composition; any explicit list without one takes full control.235 replacesGeneratedDeployInputs := c.Config.Deploy != nil &&236 c.Config.Deploy.Inputs != nil &&237 !slices.ContainsFunc(c.Config.Deploy.Inputs, plan.Layer.IsSpread)238 239 // Apply step config to the context240 for _, name := range slices.Sorted(maps.Keys(c.Config.Steps)) {241 configStep := c.Config.Steps[name]242 243 var commandStepBuilder *CommandStepBuilder244 245 if existingStep := c.GetStepByName(name); existingStep != nil {246 if csb, ok := (*existingStep).(*CommandStepBuilder); ok {247 commandStepBuilder = csb248 } else {249 log.Warnf("Step `%s` exists, but it is not a command step. Skipping...", name)250 continue251 }252 } else {253 // If no build step found, create a new one254 // Run the build in the builder context and copy the /app contents to the final image255 commandStepBuilder = c.NewCommandStep(name)256 commandStepBuilder.AddInput(plan.NewStepLayer(c.GetMiseStepBuilder().Name()))257 }258 259 commandStepBuilder.Inputs = plan.Spread(configStep.Inputs, commandStepBuilder.Inputs)260 commandStepBuilder.Commands = plan.Spread(configStep.Commands, commandStepBuilder.Commands)261 commandStepBuilder.Secrets = plan.SpreadStrings(configStep.Secrets, commandStepBuilder.Secrets)262 commandStepBuilder.Caches = plan.SpreadStrings(configStep.Caches, commandStepBuilder.Caches)263 commandStepBuilder.AddEnvVars(configStep.Variables)264 maps.Copy(commandStepBuilder.Assets, configStep.Assets)265 266 // Convert the deploy outputs into layers that will be added to the deploy.267 // Skip if the path is already covered by existing inputs from this step268 // (e.g. provider already added "." so we don't duplicate it from --build-cmd).269 outputFilters := []plan.Filter{plan.NewIncludeFilter([]string{"."})}270 if configStep.DeployOutputs != nil {271 // if deploy outputs are explicitly set on a step, then always use them, regardless of deploy configuration272 // TODO I don't like this and find it confusing: deploy.inputs should be able to override step-level deploy outputs273 outputFilters = configStep.DeployOutputs274 } else if replacesGeneratedDeployInputs || c.Deploy.HasInputForStep(name) {275 // if no deployOutput is specified on a step, the user has not specified a "..." in deploy.inputs, and276 continue277 }278 for _, filter := range outputFilters {279 if slices.ContainsFunc(filter.Include, func(inc string) bool {280 return c.Deploy.HasIncludeForStep(name, inc)281 }) {282 continue283 }284 c.Deploy.AddInputs([]plan.Layer{plan.NewStepLayer(name, filter)})285 }286 }287 288 c.notifyCustomAptDebianUpgrade()289}290 291// TODO(2026-10-17): remove this Debian upgrade notice for custom apt packages.292func (c *GenerateContext) notifyCustomAptDebianUpgrade() {293 hasCustom := false294 for _, pkg := range c.Config.BuildAptPackages {295 if pkg != "" && pkg != "..." {296 hasCustom = true297 break298 }299 }300 if !hasCustom && c.Config.Deploy != nil {301 for _, pkg := range c.Config.Deploy.AptPackages {302 if pkg != "" && pkg != "..." {303 hasCustom = true304 break305 }306 }307 }308 if !hasCustom {309 return310 }311 312 c.Logger.LogInfo("The debian base image has been upgraded and you may experience issues with custom apt packages. Report any issues here: https://github.com/railwayapp/railpack/issues")313}314 315func (c *GenerateContext) applyBuildAptPackages() {316 configuredPackages := c.Config.BuildAptPackages317 if configuredPackages == nil {318 return319 }320 321 if !slices.Contains(configuredPackages, "...") {322 // TODO the names of these configs will probably change in a future release as well...323 c.Logger.LogDeprecation("`buildAptPackages` without a `...` entry will replace Railpack packages in the future")324 c.Logger.LogSuggestion("Add `...` to `buildAptPackages` to retain Railpack packages", "/guides/installing-packages")325 326 // TODO: Remove this implicit spread so lists without "..." replace generated packages.327 configuredPackages = append([]string{"..."}, configuredPackages...)328 }329 330 miseStep := c.GetMiseStepBuilder()331 miseStep.SupportingAptPackages = plan.SpreadStrings(configuredPackages, miseStep.SupportingAptPackages)332}333 334func (c *GenerateContext) applyDeployAptPackages() {335 configuredPackages := c.Config.Deploy.AptPackages336 if configuredPackages != nil && !slices.Contains(configuredPackages, "...") {337 c.Logger.LogSuggestion("Add `...` to `deploy.aptPackages` to retain Railpack packages", "/guides/installing-packages")338 }339 340 c.Deploy.AptPackages = plan.SpreadStrings(configuredPackages, c.Deploy.AptPackages)341}342 343// in order to get around a circular dependency issue, we need to define discrete getters to interface with344// the mise package version logic.345 346func (c *GenerateContext) GetAppSource() string {347 return c.App.Source348}349 350func (c *GenerateContext) GetLogger() *logger.Logger {351 return c.Logger352}353