Files
pack/core/generate/context.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

327 lines
10 KiB
Go

package generate
import (
"fmt"
"maps"
"slices"
"sort"
"strings"
"github.com/charmbracelet/log"
a "github.com/hanzoai/pack/core/app"
"github.com/hanzoai/pack/core/config"
"github.com/hanzoai/pack/core/logger"
"github.com/hanzoai/pack/core/mise"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/resolver"
"github.com/hanzoai/pack/internal/utils"
)
type BuildStepOptions struct {
ResolvedPackages map[string]*resolver.ResolvedPackage
Caches *CacheContext
}
type StepBuilder interface {
Name() string
Build(p *plan.BuildPlan, options *BuildStepOptions) error
}
type GenerateContext struct {
App *a.App
Env *a.Environment
Config *config.Config
dockerignoreCtx *plan.DockerignoreContext
BaseImage string
Steps []StepBuilder
Deploy *DeployBuilder
Caches *CacheContext
Secrets []string
SubContexts []string
Metadata *Metadata
Resolver *resolver.Resolver
MiseStepBuilder *MiseStepBuilder
Logger *logger.Logger
}
type Command interface {
IsSpread() bool
}
type CommandWrapper struct {
Command plan.Command
}
// is a user-provided command entry a "spread" command?
func (c CommandWrapper) IsSpread() bool {
if execCmd, ok := c.Command.(plan.ExecCommand); ok {
return execCmd.Cmd == plan.ShellCommandString("...") || execCmd.Cmd == "..."
}
return false
}
func NewGenerateContext(app *a.App, env *a.Environment, config *config.Config, logger *logger.Logger) (*GenerateContext, error) {
resolver, err := resolver.NewResolver(mise.InstallDir)
if err != nil {
return nil, err
}
dockerignoreCtx, err := plan.NewDockerignoreContext(app)
if err != nil {
return nil, fmt.Errorf("failed to parse .dockerignore: %w", err)
}
if dockerignoreCtx.HasFile {
logger.LogInfo("Found .dockerignore file, applying filters")
log.Debugf("Dockerignore patterns: %v", dockerignoreCtx.Excludes)
}
ctx := &GenerateContext{
App: app,
Env: env,
Config: config,
Steps: make([]StepBuilder, 0),
Deploy: NewDeployBuilder(),
Caches: NewCacheContext(),
Secrets: []string{},
Metadata: NewMetadata(),
Resolver: resolver,
Logger: logger,
dockerignoreCtx: dockerignoreCtx,
}
ctx.applyPackagesFromConfig()
if dockerignoreCtx.HasFile {
ctx.Metadata.SetBool("dockerIgnore", true)
}
return ctx, nil
}
func (c *GenerateContext) GetMiseStepBuilder() *MiseStepBuilder {
if c.MiseStepBuilder == nil {
c.MiseStepBuilder = c.newMiseStepBuilder()
}
return c.MiseStepBuilder
}
func (c *GenerateContext) EnterSubContext(subContext string) *GenerateContext {
c.SubContexts = append(c.SubContexts, subContext)
return c
}
func (c *GenerateContext) ExitSubContext() *GenerateContext {
c.SubContexts = c.SubContexts[:len(c.SubContexts)-1]
return c
}
func (c *GenerateContext) GetStepName(name string) string {
subContextNames := strings.Join(c.SubContexts, ":")
if subContextNames != "" {
return name + ":" + subContextNames
}
return name
}
func (c *GenerateContext) GetStepByName(name string) *StepBuilder {
for _, step := range c.Steps {
if step.Name() == name {
return &step
}
}
return nil
}
func (c *GenerateContext) ResolvePackages() (map[string]*resolver.ResolvedPackage, error) {
return c.Resolver.ResolvePackages()
}
// Generate a build plan from the context
func (c *GenerateContext) Generate() (*plan.BuildPlan, map[string]*resolver.ResolvedPackage, error) {
c.applyConfig()
// Resolve all package versions into a fully qualified and valid version
resolvedPackages, err := c.ResolvePackages()
if err != nil {
return nil, nil, err
}
buildPlan := plan.NewBuildPlan()
// Merge exclude patterns from .dockerignore and railpack.json
excludePatterns := []string{}
excludePatterns = append(excludePatterns, c.dockerignoreCtx.Excludes...)
excludePatterns = append(excludePatterns, c.Config.Exclude...)
if len(excludePatterns) > 0 {
buildPlan.Exclude = excludePatterns
}
buildStepOptions := &BuildStepOptions{
ResolvedPackages: resolvedPackages,
Caches: c.Caches,
}
for _, stepBuilder := range c.Steps {
err := stepBuilder.Build(buildPlan, buildStepOptions)
if err != nil {
return nil, nil, fmt.Errorf("failed to build step: %w", err)
}
}
buildPlan.Caches = c.Caches.Caches
buildPlan.Secrets = utils.RemoveDuplicates(c.Secrets)
c.Deploy.Build(buildPlan, buildStepOptions)
buildPlan.Normalize()
return buildPlan, resolvedPackages, nil
}
func (o *BuildStepOptions) NewAptInstallCommand(pkgs []string) plan.Command {
pkgs = utils.RemoveDuplicates(pkgs)
sort.Strings(pkgs)
// sh -c is required because && is a shell operator that needs a shell to interpret it
return plan.NewExecCommand("sh -c 'apt-get update && apt-get install -y "+strings.Join(pkgs, " ")+"'", plan.ExecOptions{
CustomName: "install apt packages: " + strings.Join(pkgs, " "),
})
}
func (c *GenerateContext) applyPackagesFromConfig() {
miseStep := c.GetMiseStepBuilder()
// railpack.json supports defining custom packages, if we find them we seed the mise builder versions with those user-specified values
// other more specific version definitions (such as package.json, ENV vars, etc) will take precedence over these
for _, pkg := range slices.Sorted(maps.Keys(c.Config.Packages)) {
version := c.Config.Packages[pkg]
pkgRef := miseStep.Default(pkg, version)
// `custom config` and not `railpack.json` is used since the source of the custom config could be a CLI flag or custom config file
miseStep.Version(pkgRef, version, "custom config")
}
}
func (c *GenerateContext) applyConfig() {
c.applyPackagesFromConfig()
c.applyBuildAptPackages()
// Apply the cache config to the context
maps.Copy(c.Caches.Caches, c.Config.Caches)
c.Secrets = plan.SpreadStrings(c.Config.Secrets, c.Secrets)
// Update deploy from config
if c.Config.Deploy != nil {
if c.Config.Deploy.Base != nil && !c.Config.Deploy.Base.IsEmpty() {
c.Deploy.Base = *c.Config.Deploy.Base
}
if c.Config.Deploy.StartCmd != "" {
c.Deploy.StartCmd = c.Config.Deploy.StartCmd
}
c.applyDeployAptPackages()
c.Deploy.DeployInputs = plan.Spread(c.Config.Deploy.Inputs, c.Deploy.DeployInputs)
c.Deploy.Paths = plan.SpreadStrings(c.Config.Deploy.Paths, c.Deploy.Paths)
maps.Copy(c.Deploy.Variables, c.Config.Deploy.Variables)
}
// A spread retains generated deploy composition; any explicit list without one takes full control.
replacesGeneratedDeployInputs := c.Config.Deploy != nil &&
c.Config.Deploy.Inputs != nil &&
!slices.ContainsFunc(c.Config.Deploy.Inputs, plan.Layer.IsSpread)
// Apply step config to the context
for _, name := range slices.Sorted(maps.Keys(c.Config.Steps)) {
configStep := c.Config.Steps[name]
var commandStepBuilder *CommandStepBuilder
if existingStep := c.GetStepByName(name); existingStep != nil {
if csb, ok := (*existingStep).(*CommandStepBuilder); ok {
commandStepBuilder = csb
} else {
log.Warnf("Step `%s` exists, but it is not a command step. Skipping...", name)
continue
}
} else {
// If no build step found, create a new one
// Run the build in the builder context and copy the /app contents to the final image
commandStepBuilder = c.NewCommandStep(name)
commandStepBuilder.AddInput(plan.NewStepLayer(c.GetMiseStepBuilder().Name()))
}
commandStepBuilder.Inputs = plan.Spread(configStep.Inputs, commandStepBuilder.Inputs)
commandStepBuilder.Commands = plan.Spread(configStep.Commands, commandStepBuilder.Commands)
commandStepBuilder.Secrets = plan.SpreadStrings(configStep.Secrets, commandStepBuilder.Secrets)
commandStepBuilder.Caches = plan.SpreadStrings(configStep.Caches, commandStepBuilder.Caches)
commandStepBuilder.AddEnvVars(configStep.Variables)
maps.Copy(commandStepBuilder.Assets, configStep.Assets)
// Convert the deploy outputs into layers that will be added to the deploy.
// Skip if the path is already covered by existing inputs from this step
// (e.g. provider already added "." so we don't duplicate it from --build-cmd).
outputFilters := []plan.Filter{plan.NewIncludeFilter([]string{"."})}
if configStep.DeployOutputs != nil {
// if deploy outputs are explicitly set on a step, then always use them, regardless of deploy configuration
// TODO I don't like this and find it confusing: deploy.inputs should be able to override step-level deploy outputs
outputFilters = configStep.DeployOutputs
} else if replacesGeneratedDeployInputs || c.Deploy.HasInputForStep(name) {
// if no deployOutput is specified on a step, the user has not specified a "..." in deploy.inputs, and
continue
}
for _, filter := range outputFilters {
if slices.ContainsFunc(filter.Include, func(inc string) bool {
return c.Deploy.HasIncludeForStep(name, inc)
}) {
continue
}
c.Deploy.AddInputs([]plan.Layer{plan.NewStepLayer(name, filter)})
}
}
}
func (c *GenerateContext) applyBuildAptPackages() {
configuredPackages := c.Config.BuildAptPackages
if configuredPackages == nil {
return
}
if !slices.Contains(configuredPackages, "...") {
// TODO the names of these configs will probably change in a future release as well...
c.Logger.LogDeprecation("`buildAptPackages` without a `...` entry will replace Railpack packages in the future")
c.Logger.LogSuggestion("Add `...` to `buildAptPackages` to retain Railpack packages", "/guides/installing-packages")
// TODO: Remove this implicit spread so lists without "..." replace generated packages.
configuredPackages = append([]string{"..."}, configuredPackages...)
}
miseStep := c.GetMiseStepBuilder()
miseStep.SupportingAptPackages = plan.SpreadStrings(configuredPackages, miseStep.SupportingAptPackages)
}
func (c *GenerateContext) applyDeployAptPackages() {
configuredPackages := c.Config.Deploy.AptPackages
if configuredPackages != nil && !slices.Contains(configuredPackages, "...") {
c.Logger.LogSuggestion("Add `...` to `deploy.aptPackages` to retain Railpack packages", "/guides/installing-packages")
}
c.Deploy.AptPackages = plan.SpreadStrings(configuredPackages, c.Deploy.AptPackages)
}
// in order to get around a circular dependency issue, we need to define discrete getters to interface with
// the mise package version logic.
func (c *GenerateContext) GetAppSource() string {
return c.App.Source
}
func (c *GenerateContext) GetLogger() *logger.Logger {
return c.Logger
}