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

443 lines
14 KiB
Go

// build step that installs packages defined by mise configuration, provider configuration, or user configuration
package generate
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
"slices"
"sort"
"strings"
a "github.com/hanzoai/pack/core/app"
"github.com/hanzoai/pack/core/mise"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/resolver"
)
const (
MisePackageStepName = "packages:mise"
// System-level config at /etc/mise/config.toml is auto-trusted by mise
MiseInstallCommand = "mise install"
)
var (
RailpackBuilderImage = fmt.Sprintf("ghcr.io/railwayapp/railpack-builder:mise-%s", mise.Version)
)
// represents a app-local mise package
type MisePackageInfo struct {
Version string
Source string
}
// MiseListSource represents the source of a mise tool installation
type MiseListSource struct {
Type string `json:"type"`
Path string `json:"path"`
}
// MiseListTool represents a tool in the mise list output
type MiseListTool struct {
Version string `json:"version"`
RequestedVersion string `json:"requested_version"`
InstallPath string `json:"install_path"`
Source MiseListSource `json:"source"`
Installed bool `json:"installed"`
// --current ensures Active=true for all entries
Active bool `json:"active"`
}
// MisePackageListOutput represents the full output of `mise list --current --json`
type MisePackageListOutput map[string][]MiseListTool
type MiseStepBuilder struct {
DisplayName string
Resolver *resolver.Resolver
SupportingAptPackages []string
MisePackages []*resolver.PackageRef
SupportingMiseFiles []string
Assets map[string]string
Inputs []plan.Layer
Variables map[string]string
MiseSettings map[string]any
app *a.App
env *a.Environment
// nil = not yet computed, non-nil = cached result (may be an empty slice)
supportingMiseConfigFiles *[]string
}
func (c *GenerateContext) NewMiseStepBuilder(displayName string) *MiseStepBuilder {
step := &MiseStepBuilder{
DisplayName: displayName,
Resolver: c.Resolver,
MisePackages: []*resolver.PackageRef{},
SupportingAptPackages: []string{},
Assets: map[string]string{},
Inputs: []plan.Layer{},
Variables: map[string]string{},
MiseSettings: map[string]any{},
app: c.App,
env: c.Env,
}
c.Steps = append(c.Steps, step)
return step
}
func (c *GenerateContext) newMiseStepBuilder() *MiseStepBuilder {
step := c.NewMiseStepBuilder(MisePackageStepName)
return step
}
func (b *MiseStepBuilder) AddSupportingAptPackage(name string) {
b.SupportingAptPackages = append(b.SupportingAptPackages, name)
}
// AddMiseSetting adds a setting to the generated mise.toml [settings] section.
// Callers use dot-paths (e.g. "python.compile") so all mise settings are easy to grep for.
// This method expands them into nested maps because the TOML encoder would otherwise
// emit a quoted literal key ("python.compile") instead of a proper [settings.python] section.
// It also merges keys: independent callers can set "node.verify" and "node.corepack"
// without clobbering each other.
func (b *MiseStepBuilder) AddMiseSetting(key string, value any) {
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
b.MiseSettings[key] = value
return
}
// Ensure the nested map exists
nested, ok := b.MiseSettings[parts[0]].(map[string]any)
if !ok {
nested = map[string]any{}
b.MiseSettings[parts[0]] = nested
}
nested[parts[1]] = value
}
func (b *MiseStepBuilder) AddInput(input plan.Layer) {
b.Inputs = append(b.Inputs, input)
}
func (b *MiseStepBuilder) Default(name string, defaultVersion string) resolver.PackageRef {
for _, pkg := range b.MisePackages {
if pkg.Name == name {
return *pkg
}
}
pkg := b.Resolver.Default(name, defaultVersion)
b.MisePackages = append(b.MisePackages, &pkg)
return pkg
}
func (b *MiseStepBuilder) Version(name resolver.PackageRef, version string, source string) {
b.Resolver.Version(name, version, source)
}
func (b *MiseStepBuilder) SkipMiseInstall(name resolver.PackageRef) {
b.Resolver.SetSkipMiseInstall(name, true)
}
// GetMisePackageVersions gets all package versions from mise that are defined in the app directory environment
// this can include additional packages defined outside the app directory, but we filter those out
func (b *MiseStepBuilder) GetMisePackageVersions(ctx *GenerateContext) (map[string]*MisePackageInfo, error) {
miseInstance, err := mise.New(mise.InstallDir)
if err != nil {
return nil, err
}
appDir := ctx.GetAppSource()
output, err := miseInstance.GetCurrentList(appDir)
if err != nil {
return nil, fmt.Errorf("failed to get package versions: %w", err)
}
var listOutput MisePackageListOutput
if err := json.Unmarshal([]byte(output), &listOutput); err != nil {
return nil, fmt.Errorf("failed to parse mise list output: %w", err)
}
packages := make(map[string]*MisePackageInfo)
for toolName, tools := range listOutput {
var appDirTools []MiseListTool
for _, tool := range tools {
// Only include tools that are sourced from within the app directory
if strings.HasPrefix(tool.Source.Path, appDir) {
appDirTools = append(appDirTools, tool)
}
}
if len(appDirTools) > 1 {
versions := make([]string, len(appDirTools))
for i, tool := range appDirTools {
versions[i] = tool.Version
}
// this is possible, although in practice it should be extremely rare
ctx.GetLogger().LogWarn("Multiple versions of tool '%s' found: %v. Using the first one: %s",
toolName, versions, versions[0])
}
if len(appDirTools) > 0 {
firstTool := appDirTools[0]
packages[toolName] = &MisePackageInfo{
Version: firstTool.Version,
// include the source so we can surface this to the user so they understand where the package version came from
Source: firstTool.Source.Type,
}
}
}
return packages, nil
}
// Use mise-specified versions (including idiomatic version files) for all packages in the input list
// this overwrites any previously-specified package versions, so ENV-soured versions must be applied after this is called.
func (b *MiseStepBuilder) UseMiseVersions(ctx *GenerateContext, packageNamesToOverride []string) {
miseSpecifiedPackageVersions, err := b.GetMisePackageVersions(ctx)
if err != nil {
ctx.Logger.LogWarn("Failed to get package versions from mise: %s", err.Error())
return
}
if miseSpecifiedPackageVersions == nil {
return
}
for _, packageName := range packageNamesToOverride {
pkg := miseSpecifiedPackageVersions[packageName]
if pkg == nil {
continue
}
// Find the existing package reference in our build configuration
for _, pkgRef := range b.MisePackages {
if pkgRef.Name == packageName {
b.Version(*pkgRef, pkg.Version, pkg.Source)
break
}
}
}
}
func (b *MiseStepBuilder) Name() string {
return b.DisplayName
}
func (b *MiseStepBuilder) GetOutputPaths() []string {
if len(b.MisePackages) == 0 && len(b.getSupportingMiseConfigFiles()) == 0 {
return []string{}
}
return []string{"/mise/shims", "/mise/installs", "/usr/local/bin/mise", "/etc/mise/config.toml", "/root/.local/state/mise"}
}
func (b *MiseStepBuilder) GetLayer() plan.Layer {
outputPaths := b.GetOutputPaths()
if len(outputPaths) == 0 {
return plan.Layer{}
}
return plan.NewStepLayer(b.Name(), plan.Filter{
Include: outputPaths,
})
}
func (b *MiseStepBuilder) Build(p *plan.BuildPlan, options *BuildStepOptions) error {
baseLayer := plan.NewImageLayer(RailpackBuilderImage)
if len(b.SupportingAptPackages) > 0 {
aptStep := plan.NewStep("packages:apt:build")
aptStep.Inputs = []plan.Layer{baseLayer}
aptStep.AddCommands([]plan.Command{
options.NewAptInstallCommand(b.SupportingAptPackages),
})
aptStep.Caches = options.Caches.GetAptCaches()
aptStep.Secrets = []string{}
p.Steps = append(p.Steps, *aptStep)
baseLayer = plan.NewStepLayer(aptStep.Name)
}
step := plan.NewStep(b.DisplayName)
step.Inputs = []plan.Layer{baseLayer}
supportingMiseConfigFiles := b.getSupportingMiseConfigFiles()
if len(b.MisePackages) > 0 || len(supportingMiseConfigFiles) > 0 {
step.AddCommands([]plan.Command{plan.NewPathCommand("/mise/shims")})
// NOTE make sure to keep (some) of the variables below in sync with install_bin_builder
maps.Copy(step.Variables, map[string]string{
"MISE_DATA_DIR": "/mise",
"MISE_CONFIG_DIR": "/mise",
"MISE_CACHE_DIR": "/mise/cache",
"MISE_SHIMS_DIR": "/mise/shims",
"MISE_INSTALLS_DIR": "/mise/installs",
})
maps.Copy(step.Variables, b.Variables)
// Base settings written into [settings] of the generated mise.toml so users can override with their own mise.toml
// Some of these settings (i.e. `install_before`) should be set in the host mise execution
// Don't verify the asset because recently released versions don't have a public key to verify against
// https://github.com/hanzoai/pack/issues/207
b.AddMiseSetting("node.verify", false)
// Enforces HTTPS and stricter security
b.AddMiseSetting("paranoid", true)
// Trust config files in the app directory to avoid trust warnings during build
b.AddMiseSetting("trusted_config_paths", []string{"/app"})
// Enable mise to automatically read idiomatic version files
b.AddMiseSetting("idiomatic_version_file_enable_tools", strings.Split(mise.IdiomaticVersionFileTools, ","))
// Only resolve tool versions released more than 14 days ago to avoid broken newly-released versions
b.AddMiseSetting("minimum_release_age", "14d")
// pass through the MISE_VERBOSE variable for detailed logging
if verbose := b.env.GetVariable("MISE_VERBOSE"); verbose != "" {
step.Variables["MISE_VERBOSE"] = verbose
}
// Add user mise config files if they exist
for _, file := range supportingMiseConfigFiles {
step.AddCommands([]plan.Command{
plan.NewCopyCommand(file),
})
}
// Setup mise commands
packagesToInstall := make(map[string]string)
for _, pkg := range b.MisePackages {
resolved, ok := options.ResolvedPackages[pkg.Name]
if ok && resolved.ResolvedVersion != nil && !b.Resolver.Get(pkg.Name).SkipMiseInstall {
packagesToInstall[pkg.Name] = *resolved.ResolvedVersion
}
}
miseToml, err := mise.GenerateMiseToml(packagesToInstall, b.MiseSettings)
if err != nil {
return fmt.Errorf("failed to generate mise.toml: %w", err)
}
// use a `generated-` to make it clear to the plan reader that this is system-generated, not user provided
b.Assets["generated-mise-toml"] = miseToml
pkgNames := make([]string, 0, len(packagesToInstall))
for k := range packagesToInstall {
pkgNames = append(pkgNames, k)
}
sort.Strings(pkgNames)
step.AddCommands([]plan.Command{
plan.NewFileCommand("/etc/mise/config.toml", "generated-mise-toml", plan.FileOptions{
CustomName: "create mise config",
}),
plan.NewExecCommand(MiseInstallCommand, plan.ExecOptions{
CustomName: "install mise packages: " + strings.Join(pkgNames, ", "),
}),
})
}
step.Assets = b.Assets
step.Secrets = []string{}
p.Steps = append(p.Steps, *step)
return nil
}
// https://mise.jdx.dev/configuration.html#idiomatic-version-files
var miseIdiomaticFiles = []string{
".python-version",
".python-versions",
".node-version",
".nvmrc",
".ruby-version",
"Gemfile",
".go-version",
".java-version",
".sdkmanrc",
".exenv-version",
".deno-version",
"rust-toolchain.toml",
// .bun-version is a community convention, not officially supported by Bun
".bun-version",
".yvmrc",
"global.json",
}
// https://mise.jdx.dev/configuration.html#configuration-hierarchy
var miseConfigFiles = []string{
"mise.toml",
".mise.toml",
"mise/config.toml",
".mise/config.toml",
".config/mise.toml",
".config/mise/config.toml",
".tool-versions",
}
// https://mise.jdx.dev/configuration.html#mise-toml
// the env-specific mise files are not as well documented, but we should look for them and include them in the install
// step in case a user specified a MISE_ENV. They won't negatively impact builds otherwise (outside of more frequency cache busting)
var miseConfigGlobs = []string{
"mise.*.toml",
".mise.*.toml",
".config/mise/conf.d/*.toml",
}
// This logic casts a wide net to find any mise configuration that may exist in the app source.
// this enables the user to use mise config to configure the runtime, options, etc in a pretty granular way but also
// requires the user to understand how to enable mise for various environments if they have a more advanced configuration
func (b *MiseStepBuilder) getSupportingMiseConfigFiles() []string {
// depending on the size of the app source, this *could* be a expensive operation, so we cache the results so we can
// call multiple times without concern.
if b.supportingMiseConfigFiles != nil {
return *b.supportingMiseConfigFiles
}
seen := map[string]bool{}
files := []string{}
add := func(file string) {
if !seen[file] {
seen[file] = true
files = append(files, file)
}
}
for _, file := range slices.Concat(miseConfigFiles, miseIdiomaticFiles) {
if b.app.HasFile(file) {
add(file)
}
}
for _, pattern := range miseConfigGlobs {
matches, err := b.app.FindFiles(pattern)
if err != nil {
continue
}
for _, match := range matches {
add(match)
}
}
// For each directory containing a toml config, also check for a co-located mise.lock
for _, file := range files {
if !strings.HasSuffix(file, ".toml") {
continue
}
if lockFile := filepath.Join(filepath.Dir(file), "mise.lock"); b.app.HasFile(lockFile) {
add(lockFile)
}
}
b.supportingMiseConfigFiles = &files
return files
}