core/generate/mise_step_builder.go
core/generate/mise_step_builder.goBrowse 1970 files
3,659 tokens
13,881 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1// build step that installs packages defined by mise configuration, provider configuration, or user configuration2package generate3 4import (5 "encoding/json"6 "fmt"7 "maps"8 "path/filepath"9 "slices"10 "sort"11 "strings"12 13 a "github.com/railwayapp/railpack/core/app"14 "github.com/railwayapp/railpack/core/mise"15 "github.com/railwayapp/railpack/core/plan"16 "github.com/railwayapp/railpack/core/resolver"17)18 19const (20 MisePackageStepName = "packages:mise"21 // System-level config at /etc/mise/config.toml is auto-trusted by mise22 MiseInstallCommand = "mise install"23)24 25var (26 RailpackBuilderImage = fmt.Sprintf("ghcr.io/railwayapp/railpack-builder:mise-%s", mise.Version)27)28 29// represents a app-local mise package30type MisePackageInfo struct {31 Version string32 Source string33}34 35// MiseListSource represents the source of a mise tool installation36type MiseListSource struct {37 Type string `json:"type"`38 Path string `json:"path"`39}40 41// MiseListTool represents a tool in the mise list output42type MiseListTool struct {43 Version string `json:"version"`44 RequestedVersion string `json:"requested_version"`45 InstallPath string `json:"install_path"`46 Source MiseListSource `json:"source"`47 Installed bool `json:"installed"`48 // --current ensures Active=true for all entries49 Active bool `json:"active"`50}51 52// MisePackageListOutput represents the full output of `mise list --current --json`53type MisePackageListOutput map[string][]MiseListTool54 55type MiseStepBuilder struct {56 DisplayName string57 Resolver *resolver.Resolver58 SupportingAptPackages []string59 MisePackages []*resolver.PackageRef60 SupportingMiseFiles []string61 Assets map[string]string62 Inputs []plan.Layer63 Variables map[string]string64 MiseSettings map[string]any65 app *a.App66 env *a.Environment67 // nil = not yet computed, non-nil = cached result (may be an empty slice)68 supportingMiseConfigFiles *[]string69}70 71func (c *GenerateContext) NewMiseStepBuilder(displayName string) *MiseStepBuilder {72 step := &MiseStepBuilder{73 DisplayName: displayName,74 Resolver: c.Resolver,75 MisePackages: []*resolver.PackageRef{},76 SupportingAptPackages: []string{},77 Assets: map[string]string{},78 Inputs: []plan.Layer{},79 Variables: map[string]string{},80 MiseSettings: map[string]any{},81 app: c.App,82 env: c.Env,83 }84 85 c.Steps = append(c.Steps, step)86 87 return step88}89 90func (c *GenerateContext) newMiseStepBuilder() *MiseStepBuilder {91 step := c.NewMiseStepBuilder(MisePackageStepName)92 93 return step94}95 96func (b *MiseStepBuilder) AddSupportingAptPackage(name string) {97 b.SupportingAptPackages = append(b.SupportingAptPackages, name)98}99 100// AddMiseSetting adds a setting to the generated mise.toml [settings] section.101// Callers use dot-paths (e.g. "python.compile") so all mise settings are easy to grep for.102// This method expands them into nested maps because the TOML encoder would otherwise103// emit a quoted literal key ("python.compile") instead of a proper [settings.python] section.104// It also merges keys: independent callers can set "node.verify" and "node.corepack"105// without clobbering each other.106func (b *MiseStepBuilder) AddMiseSetting(key string, value any) {107 parts := strings.SplitN(key, ".", 2)108 if len(parts) == 1 {109 b.MiseSettings[key] = value110 return111 }112 113 // Ensure the nested map exists114 nested, ok := b.MiseSettings[parts[0]].(map[string]any)115 if !ok {116 nested = map[string]any{}117 b.MiseSettings[parts[0]] = nested118 }119 nested[parts[1]] = value120}121 122func (b *MiseStepBuilder) AddInput(input plan.Layer) {123 b.Inputs = append(b.Inputs, input)124}125 126func (b *MiseStepBuilder) Default(name string, defaultVersion string) resolver.PackageRef {127 for _, pkg := range b.MisePackages {128 if pkg.Name == name {129 return *pkg130 }131 }132 133 pkg := b.Resolver.Default(name, defaultVersion)134 b.MisePackages = append(b.MisePackages, &pkg)135 return pkg136}137 138func (b *MiseStepBuilder) Version(name resolver.PackageRef, version string, source string) {139 b.Resolver.Version(name, version, source)140}141 142func (b *MiseStepBuilder) SkipMiseInstall(name resolver.PackageRef) {143 b.Resolver.SetSkipMiseInstall(name, true)144}145 146// GetMisePackageVersions gets all package versions from mise that are defined in the app directory environment147// this can include additional packages defined outside the app directory, but we filter those out148func (b *MiseStepBuilder) GetMisePackageVersions(ctx *GenerateContext) (map[string]*MisePackageInfo, error) {149 miseInstance, err := mise.New(mise.InstallDir)150 if err != nil {151 return nil, err152 }153 154 appDir := ctx.GetAppSource()155 output, err := miseInstance.GetCurrentList(appDir)156 if err != nil {157 return nil, fmt.Errorf("failed to get package versions: %w", err)158 }159 160 var listOutput MisePackageListOutput161 if err := json.Unmarshal([]byte(output), &listOutput); err != nil {162 return nil, fmt.Errorf("failed to parse mise list output: %w", err)163 }164 165 packages := make(map[string]*MisePackageInfo)166 167 for toolName, tools := range listOutput {168 var appDirTools []MiseListTool169 for _, tool := range tools {170 // Only include tools that are sourced from within the app directory171 if strings.HasPrefix(tool.Source.Path, appDir) {172 appDirTools = append(appDirTools, tool)173 }174 }175 176 if len(appDirTools) > 1 {177 versions := make([]string, len(appDirTools))178 for i, tool := range appDirTools {179 versions[i] = tool.Version180 }181 182 // this is possible, although in practice it should be extremely rare183 ctx.GetLogger().LogWarn("Multiple versions of tool '%s' found: %v. Using the first one: %s",184 toolName, versions, versions[0])185 }186 187 if len(appDirTools) > 0 {188 firstTool := appDirTools[0]189 packages[toolName] = &MisePackageInfo{190 Version: firstTool.Version,191 // include the source so we can surface this to the user so they understand where the package version came from192 Source: firstTool.Source.Type,193 }194 }195 }196 197 return packages, nil198}199 200// Use mise-specified versions (including idiomatic version files) for all packages in the input list201// this overwrites any previously-specified package versions, so ENV-soured versions must be applied after this is called.202func (b *MiseStepBuilder) UseMiseVersions(ctx *GenerateContext, packageNamesToOverride []string) {203 miseSpecifiedPackageVersions, err := b.GetMisePackageVersions(ctx)204 if err != nil {205 ctx.Logger.LogWarn("Failed to get package versions from mise: %s", err.Error())206 return207 }208 209 if miseSpecifiedPackageVersions == nil {210 return211 }212 213 for _, packageName := range packageNamesToOverride {214 pkg := miseSpecifiedPackageVersions[packageName]215 if pkg == nil {216 continue217 }218 219 // Find the existing package reference in our build configuration220 for _, pkgRef := range b.MisePackages {221 if pkgRef.Name == packageName {222 b.Version(*pkgRef, pkg.Version, pkg.Source)223 break224 }225 }226 }227}228 229func (b *MiseStepBuilder) Name() string {230 return b.DisplayName231}232 233func (b *MiseStepBuilder) GetOutputPaths() []string {234 if len(b.MisePackages) == 0 && len(b.getSupportingMiseConfigFiles()) == 0 {235 return []string{}236 }237 238 return []string{"/mise/shims", "/mise/installs", "/usr/local/bin/mise", "/etc/mise/config.toml", "/root/.local/state/mise"}239}240 241func (b *MiseStepBuilder) GetLayer() plan.Layer {242 outputPaths := b.GetOutputPaths()243 if len(outputPaths) == 0 {244 return plan.Layer{}245 }246 247 return plan.NewStepLayer(b.Name(), plan.Filter{248 Include: outputPaths,249 })250}251 252func (b *MiseStepBuilder) Build(p *plan.BuildPlan, options *BuildStepOptions) error {253 baseLayer := plan.NewImageLayer(RailpackBuilderImage)254 255 if len(b.SupportingAptPackages) > 0 {256 aptStep := plan.NewStep("packages:apt:build")257 aptStep.Inputs = []plan.Layer{baseLayer}258 aptStep.AddCommands([]plan.Command{259 options.NewAptInstallCommand(b.SupportingAptPackages),260 })261 aptStep.Caches = options.Caches.GetAptCaches()262 aptStep.Secrets = []string{}263 264 p.Steps = append(p.Steps, *aptStep)265 baseLayer = plan.NewStepLayer(aptStep.Name)266 }267 268 step := plan.NewStep(b.DisplayName)269 270 step.Inputs = []plan.Layer{baseLayer}271 272 supportingMiseConfigFiles := b.getSupportingMiseConfigFiles()273 if len(b.MisePackages) > 0 || len(supportingMiseConfigFiles) > 0 {274 step.AddCommands([]plan.Command{plan.NewPathCommand("/mise/shims")})275 // NOTE make sure to keep (some) of the variables below in sync with install_bin_builder276 maps.Copy(step.Variables, map[string]string{277 "MISE_DATA_DIR": "/mise",278 "MISE_CONFIG_DIR": "/mise",279 "MISE_CACHE_DIR": "/mise/cache",280 "MISE_SHIMS_DIR": "/mise/shims",281 "MISE_INSTALLS_DIR": "/mise/installs",282 })283 maps.Copy(step.Variables, b.Variables)284 285 // Base settings written into [settings] of the generated mise.toml so users can override with their own mise.toml286 // Some of these settings (i.e. `install_before`) should be set in the host mise execution287 288 // Don't verify the asset because recently released versions don't have a public key to verify against289 // https://github.com/railwayapp/railpack/issues/207290 b.AddMiseSetting("node.verify", false)291 // Enforces HTTPS and stricter security292 b.AddMiseSetting("paranoid", true)293 // Trust config files in the app directory to avoid trust warnings during build294 b.AddMiseSetting("trusted_config_paths", []string{"/app"})295 // Enable mise to automatically read idiomatic version files296 b.AddMiseSetting("idiomatic_version_file_enable_tools", strings.Split(mise.IdiomaticVersionFileTools, ","))297 // Only resolve tool versions released more than 14 days ago to avoid broken newly-released versions298 b.AddMiseSetting("minimum_release_age", "14d")299 300 // pass through the MISE_VERBOSE variable for detailed logging301 if verbose := b.env.GetVariable("MISE_VERBOSE"); verbose != "" {302 step.Variables["MISE_VERBOSE"] = verbose303 }304 305 // Add user mise config files if they exist306 for _, file := range supportingMiseConfigFiles {307 step.AddCommands([]plan.Command{308 plan.NewCopyCommand(file),309 })310 }311 312 // Setup mise commands313 packagesToInstall := make(map[string]string)314 for _, pkg := range b.MisePackages {315 resolved, ok := options.ResolvedPackages[pkg.Name]316 317 if ok && resolved.ResolvedVersion != nil && !b.Resolver.Get(pkg.Name).SkipMiseInstall {318 packagesToInstall[pkg.Name] = *resolved.ResolvedVersion319 }320 }321 322 miseToml, err := mise.GenerateMiseToml(packagesToInstall, b.MiseSettings)323 if err != nil {324 return fmt.Errorf("failed to generate mise.toml: %w", err)325 }326 327 // use a `generated-` to make it clear to the plan reader that this is system-generated, not user provided328 b.Assets["generated-mise-toml"] = miseToml329 330 pkgNames := make([]string, 0, len(packagesToInstall))331 for k := range packagesToInstall {332 pkgNames = append(pkgNames, k)333 }334 sort.Strings(pkgNames)335 336 step.AddCommands([]plan.Command{337 plan.NewFileCommand("/etc/mise/config.toml", "generated-mise-toml", plan.FileOptions{338 CustomName: "create mise config",339 }),340 plan.NewExecCommand(MiseInstallCommand, plan.ExecOptions{341 CustomName: "install mise packages: " + strings.Join(pkgNames, ", "),342 }),343 })344 }345 346 step.Assets = b.Assets347 step.Secrets = []string{}348 349 p.Steps = append(p.Steps, *step)350 351 return nil352}353 354// https://mise.jdx.dev/configuration.html#idiomatic-version-files355var miseIdiomaticFiles = []string{356 ".python-version",357 ".python-versions",358 ".node-version",359 ".nvmrc",360 ".ruby-version",361 "Gemfile",362 ".go-version",363 ".java-version",364 ".sdkmanrc",365 ".exenv-version",366 ".deno-version",367 "rust-toolchain.toml",368 // .bun-version is a community convention, not officially supported by Bun369 ".bun-version",370 ".yvmrc",371 "global.json",372}373 374// https://mise.jdx.dev/configuration.html#configuration-hierarchy375var miseConfigFiles = []string{376 "mise.toml",377 ".mise.toml",378 "mise/config.toml",379 ".mise/config.toml",380 ".config/mise.toml",381 ".config/mise/config.toml",382 ".tool-versions",383}384 385// https://mise.jdx.dev/configuration.html#mise-toml386// the env-specific mise files are not as well documented, but we should look for them and include them in the install387// step in case a user specified a MISE_ENV. They won't negatively impact builds otherwise (outside of more frequency cache busting)388var miseConfigGlobs = []string{389 "mise.*.toml",390 ".mise.*.toml",391 ".config/mise/conf.d/*.toml",392}393 394// This logic casts a wide net to find any mise configuration that may exist in the app source.395// this enables the user to use mise config to configure the runtime, options, etc in a pretty granular way but also396// requires the user to understand how to enable mise for various environments if they have a more advanced configuration397func (b *MiseStepBuilder) getSupportingMiseConfigFiles() []string {398 // depending on the size of the app source, this *could* be a expensive operation, so we cache the results so we can399 // call multiple times without concern.400 if b.supportingMiseConfigFiles != nil {401 return *b.supportingMiseConfigFiles402 }403 404 seen := map[string]bool{}405 files := []string{}406 407 add := func(file string) {408 if !seen[file] {409 seen[file] = true410 files = append(files, file)411 }412 }413 414 for _, file := range slices.Concat(miseConfigFiles, miseIdiomaticFiles) {415 if b.app.HasFile(file) {416 add(file)417 }418 }419 420 for _, pattern := range miseConfigGlobs {421 matches, err := b.app.FindFiles(pattern)422 if err != nil {423 continue424 }425 for _, match := range matches {426 add(match)427 }428 }429 430 // For each directory containing a toml config, also check for a co-located mise.lock431 for _, file := range files {432 if !strings.HasSuffix(file, ".toml") {433 continue434 }435 if lockFile := filepath.Join(filepath.Dir(file), "mise.lock"); b.app.HasFile(lockFile) {436 add(lockFile)437 }438 }439 440 b.supportingMiseConfigFiles = &files441 return files442}443