core/providers/elixir/elixir.go
core/providers/elixir/elixir.goBrowse 1970 files
2,782 tokens
10,019 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1package elixir2 3import (4 "bufio"5 "fmt"6 "maps"7 "regexp"8 "strings"9 10 "github.com/railwayapp/railpack/core/app"11 "github.com/railwayapp/railpack/core/generate"12 "github.com/railwayapp/railpack/core/plan"13 "github.com/railwayapp/railpack/core/providers/node"14 "github.com/railwayapp/railpack/internal/utils"15)16 17const (18 // default elixir and erlang versions should be receiving security updates19 // https://hexdocs.pm/elixir/compatibility-and-deprecations.html20 DEFAULT_ERLANG_VERSION = "27.3"21 DEFAULT_ELIXIR_VERSION = "1.18"22 23 APP_BIN_PATH = "/app/bin/server"24 MIX_ROOT = "/root/.mix"25)26 27type ElixirProvider struct {28}29 30func (p *ElixirProvider) Name() string {31 return "Elixir"32}33 34func (p *ElixirProvider) Detect(ctx *generate.GenerateContext) (bool, error) {35 hasMixFile := ctx.App.HasFile("mix.exs")36 return hasMixFile, nil37}38 39func (p *ElixirProvider) Initialize(ctx *generate.GenerateContext) error {40 return nil41}42 43func (p *ElixirProvider) Plan(ctx *generate.GenerateContext) error {44 miseStep := ctx.GetMiseStepBuilder()45 // ELIXIR_ERL_OPTIONS can impact the install process, so we should use the same set of variables during the mise stage46 maps.Copy(miseStep.Variables, p.GetEnvVars(ctx))47 p.InstallMisePackages(ctx, miseStep)48 49 install := ctx.NewCommandStep("install")50 install.AddInput(plan.NewStepLayer(miseStep.Name()))51 install.Secrets = []string{}52 install.UseSecretsWithPrefixes([]string{"MIX", "ERL", "ELIXIR", "OTP"})53 installOutputPaths := p.Install(ctx, install)54 maps.Copy(install.Variables, p.GetEnvVars(ctx))55 56 build := ctx.NewCommandStep("build")57 build.AddInput(plan.NewStepLayer(miseStep.Name()))58 build.AddInput(plan.NewStepLayer(install.Name(), plan.Filter{59 Include: installOutputPaths,60 }))61 maps.Copy(build.Variables, p.GetEnvVars(ctx))62 buildOutputPaths := p.Build(ctx, build)63 64 maps.Copy(ctx.Deploy.Variables, p.GetEnvVars(ctx))65 ctx.Deploy.AddInputs([]plan.Layer{66 plan.NewStepLayer(build.Name(), plan.Filter{67 Include: buildOutputPaths,68 }),69 })70 ctx.Deploy.StartCmd = p.GetStartCommand(ctx)71 72 // Node (if necessary)73 if err := p.InstallNode(ctx, build); err != nil {74 return err75 }76 77 return nil78}79 80func (p *ElixirProvider) CleansePlan(buildPlan *plan.BuildPlan) {}81 82func (p *ElixirProvider) StartCommandHelp() string {83 return "To start your Elixir application, Railpack will look for:\n\n" +84 "1. A mix.exs file in your project root\n\n" +85 "The start command will run your application server using the generated release."86}87 88func (p *ElixirProvider) GetStartCommand(ctx *generate.GenerateContext) string {89 binName := p.findBinName(ctx)90 return fmt.Sprintf("/app/_build/prod/rel/%s/bin/%s start", binName, binName)91}92 93func (p *ElixirProvider) Install(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string {94 // it's possible, but rare, for an elixir project to have no mix.lock95 // https://github.com/elixir-lang/elixir/issues/1350696 // errors when these are occur are cryptic (cache key errors), so we warn the user97 if !ctx.App.HasFile("mix.lock") {98 ctx.Logger.LogWarn("No mix.lock found. Add mix.lock or customize build to avoid failure.")99 }100 101 install.AddCommands([]plan.Command{102 plan.NewExecCommand("mkdir -p config deps _build"),103 plan.NewExecCommand("mix local.hex --force"),104 plan.NewExecCommand("mix local.rebar --force"),105 plan.NewCopyCommand("mix.exs"),106 plan.NewCopyCommand("mix.lock"),107 plan.NewExecCommand("mix deps.get --only prod"),108 plan.NewCopyCommand("config/config.exs*", "config/"),109 plan.NewCopyCommand("config/prod.exs*", "config/"),110 plan.NewExecCommand("mix deps.compile"),111 })112 if matches := ctx.App.FindFilesWithContent("mix.exs", regexp.MustCompile(`assets\.setup`)); len(matches) > 0 {113 install.AddCommand(plan.NewExecCommand("mix assets.setup"))114 }115 return []string{"deps", "_build", "config", "mix.exs", "mix.lock", MIX_ROOT}116}117 118func (p *ElixirProvider) InstallNode(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) error {119 // All providers assume they're running in the application root120 // but Phoenix puts it in the assets folder, so we have to lie to the provider121 assetsApp, err := app.NewApp(ctx.App.Source + "/assets")122 if err != nil {123 // If the assets folder doesn't exist, then it isn't an error, we just don't need to install Node124 return nil125 }126 defer func(originalApp *app.App) { ctx.App = originalApp }(ctx.App)127 ctx.App = assetsApp128 129 nodeProvider := node.NodeProvider{}130 isNode, err := nodeProvider.Detect(ctx)131 if err != nil {132 return err133 }134 if !isNode {135 return nil136 }137 138 err = nodeProvider.Initialize(ctx)139 if err != nil {140 return err141 }142 143 miseStep := ctx.GetMiseStepBuilder()144 nodeProvider.InstallMisePackages(ctx, miseStep)145 146 installNode := ctx.NewCommandStep("install:node")147 installNode.AddInput(plan.NewStepLayer(miseStep.Name()))148 nodeProvider.InstallNodeDeps(ctx, installNode)149 150 // Again, the provider thinks it's in the root folder, but is actually in assets151 // So we have to modify all copy commands152 for idx, cmd := range installNode.Commands {153 if copyCmd, ok := cmd.(plan.CopyCommand); ok {154 copyCmd.Src = "assets/" + copyCmd.Src155 installNode.Commands[idx] = copyCmd156 }157 }158 159 // esbuild knows how to load node_modules from the root, so we don't have to copy it to the assets folder160 build.AddInput(plan.NewStepLayer(installNode.Name(), plan.Filter{161 Include: []string{"node_modules"},162 }))163 164 return nil165}166 167func (p *ElixirProvider) Build(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) []string {168 build.AddCommands([]plan.Command{169 plan.NewCopyCommand("priv*", "."),170 plan.NewCopyCommand("lib*", "."),171 plan.NewCopyCommand("assets*", "."),172 plan.NewCopyCommand("config/runtime.exs*", "config/"),173 plan.NewExecCommand("mix compile"),174 })175 if matches := ctx.App.FindFilesWithContent("mix.exs", regexp.MustCompile(`assets\.deploy`)); len(matches) > 0 {176 build.AddCommand(plan.NewExecCommand("mix assets.deploy"))177 }178 if matches := ctx.App.FindFilesWithContent("mix.exs", regexp.MustCompile(`ecto\.deploy`)); len(matches) > 0 {179 build.AddCommand(plan.NewExecCommand("mix ecto.deploy"))180 }181 build.AddCommands([]plan.Command{182 plan.NewCopyCommand("rel*", "."),183 plan.NewExecCommand("mix release"),184 })185 186 return []string{"_build/prod/rel"}187}188 189var elixirVersionRegex = regexp.MustCompile(`(elixir:[\s].*[> ])([\w|\.]*)`)190 191func (p *ElixirProvider) InstallMisePackages(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder) {192 elixir := miseStep.Default("elixir", DEFAULT_ELIXIR_VERSION)193 194 if mixExs, err := ctx.App.ReadFile("mix.exs"); err == nil {195 if match := elixirVersionRegex.FindStringSubmatch(mixExs); len(match) > 2 {196 version := utils.ExtractSemverVersion(match[2])197 if version != "" {198 miseStep.Version(elixir, version, "mix.exs")199 }200 }201 }202 203 if versionFile, err := ctx.App.ReadFile(".elixir-version"); err == nil {204 miseStep.Version(elixir, strings.TrimSpace(string(versionFile)), ".elixir-version")205 }206 207 if envVersion, varName := ctx.Env.GetConfigVariable("ELIXIR_VERSION"); envVersion != "" {208 miseStep.Version(elixir, envVersion, varName)209 }210 211 erlang := miseStep.Default("erlang", DEFAULT_ERLANG_VERSION)212 213 pkgs, err := miseStep.Resolver.ResolvePackages()214 elixirVersion := DEFAULT_ELIXIR_VERSION215 if err == nil && pkgs["elixir"] != nil && pkgs["elixir"].ResolvedVersion != nil {216 elixirVersion = *pkgs["elixir"].ResolvedVersion217 }218 219 elixirSemverVersion := utils.ExtractSemverVersion(elixirVersion)220 elixirSemver, err := utils.ParseSemver(elixirSemverVersion)221 222 if err == nil {223 compatibleErlangVersion := getCompatibleErlangVersion(fmt.Sprintf("%d.%d", elixirSemver.Major, elixirSemver.Minor))224 miseStep.Version(erlang, compatibleErlangVersion, "default compatible OTP version")225 }226 227 versionParts := strings.Split(elixirVersion, "-otp-")228 if len(versionParts) > 1 {229 otpVersion := versionParts[1]230 otpSemverVersion := utils.ExtractSemverVersion(otpVersion)231 if _, err := utils.ParseSemver(otpSemverVersion); err == nil {232 miseStep.Version(erlang, otpSemverVersion, "resolved compatible OTP version")233 }234 }235 236 if versionFile, err := ctx.App.ReadFile(".erlang-version"); err == nil {237 miseStep.Version(erlang, strings.TrimSpace(string(versionFile)), ".erlang-version")238 }239 240 if envVersion, varName := ctx.Env.GetConfigVariable("ERLANG_VERSION"); envVersion != "" {241 miseStep.Version(erlang, envVersion, varName)242 }243 244 miseStep.UseMiseVersions(ctx, []string{"elixir", "erlang"})245}246 247func (p *ElixirProvider) GetEnvVars(ctx *generate.GenerateContext) map[string]string {248 return map[string]string{249 "LANG": "en_US.UTF-8",250 "LANGUAGE": "en_US:en",251 "LC_ALL": "en_US.UTF-8",252 "ELIXIR_ERL_OPTIONS": "+fnu",253 "MIX_ENV": "prod",254 "MIX_HOME": MIX_ROOT,255 "MIX_ARCHIVES": MIX_ROOT + "/archives",256 }257}258 259func (p *ElixirProvider) findBinName(ctx *generate.GenerateContext) string {260 configFile, err := ctx.App.ReadFile("mix.exs")261 if err != nil {262 return ""263 }264 265 scanner := bufio.NewScanner(strings.NewReader(configFile))266 for scanner.Scan() {267 line := scanner.Text()268 if strings.Contains(line, "app: :") {269 binName := strings.Split(strings.Replace(line, "app:", "", 1), ":")[1]270 binName = strings.TrimSpace(strings.Trim(binName, ",'\""))271 return binName272 }273 }274 275 if err := scanner.Err(); err != nil {276 return ""277 }278 279 return ""280}281 282// See: https://hexdocs.pm/elixir/1.18.3/compatibility-and-deprecations.html#between-elixir-and-erlang-otp283func getCompatibleErlangVersion(elixirVersion string) string {284 switch elixirVersion {285 case "1.0", "1.1":286 return "18"287 case "1.2", "1.3":288 return "19"289 case "1.4":290 return "20"291 case "1.5":292 return "20"293 case "1.6":294 return "21"295 case "1.7", "1.8", "1.9":296 return "22"297 case "1.10":298 return "23"299 case "1.11", "1.12":300 return "24"301 case "1.13":302 return "25"303 case "1.14":304 return "26"305 case "1.15", "1.16":306 return "26"307 case "1.17", "1.18":308 return "27"309 case "1.19":310 return "28"311 case "1.20":312 return "29"313 default:314 return DEFAULT_ERLANG_VERSION315 }316}317