Current section

Files

Jump to
workspace lib mix tasks workspace.run.ex
Raw

lib/mix/tasks/workspace.run.ex

defmodule Mix.Tasks.Workspace.Run do
opts = [
task: [
type: :string,
short: "t",
doc: "The task to execute",
required: true
],
execution_mode: [
type: :string,
default: "process",
doc: """
The execution mode. It supports the following values:
- `process` - every subcommand will be executed as a different process, this
is the preferred mode for most mix tasks
- `in-project` - invokes `Mix.Task.run` from the workspace in the given project
without creating a new process (**notice that this is experimental and may not work properly
for some commands**)
"""
],
only_roots: [
type: :boolean,
doc: "If set, the task will be executed only on graph's root nodes."
],
dry_run: [
type: :boolean,
doc: "If set it will not execute the command, useful for testing and debugging.",
default: false
],
env_var: [
type: :string,
doc: """
Optional environment variables to be set before command execution. They are
expected to be in the form `ENV_VAR_NAME=value`. You can use this multiple times
for setting multiple variables.\
""",
multiple: true
],
allow_failure: [
type: :string,
doc: """
Allow the task for this specific project to fail. Can be set more than once.
""",
multiple: true
],
early_stop: [
type: :boolean,
doc: """
If set the execution will stop if the execution of any project failed
""",
default: false
],
partitions: [
type: :integer,
doc: """
Sets the number of partitions to split executions in. It must be a number
greater than zero. If set to `1` it acts as a no-op. If more than one you
must also set the the `WORKSPACE_RUN_PARTITION` environment variable with
the partition to use in the current execution. See the "Run partitioning"
section for more details
"""
]
]
@options_schema Workspace.Cli.options(
[
:workspace_path,
:config_path,
:project,
:exclude,
:tags,
:excluded_tags,
:affected,
:modified,
:base,
:head,
:verbose,
:show_status
],
opts
)
@shortdoc "Run a mix command to all projects"
@moduledoc """
Run a mix task on one or more workspace projects.
Monorepos can have hundreds of projects so being able to run a task against all
or a subset of them is a key feature of `Workspace`.
## The project graph
The core concept of `Workspace` is the project graph. This is a directed acyclic
graphs depicting the internal project dependencies. This is used by all tasks
internally in order to specify the status of the projects.
Assume you have the following workspace:
```
package_a
├── package_b ✚
│ └── package_g
├── package_c
│ ├── package_e
│ └── package_f
│ └── package_g
└── package_d
package_h ✚
└── package_d
```
It consists of various packages, and two of them (`_b` and `_h`) are modified. Using
the graph we can find the dependencies between packages and **limit the execution of
a task** only to the subset that makes sense.
**Using the proper execution strategy wisely significantly improves the CI execution
time**.
> #### Where to run a task? {: .tip}
>
> This clearly depends on the type of the task. Let's see some examples:
>
> - `mix format` makes sense to be executed only on the modified packages.
> - `mix test` should be executed on the modified and the parents of them since a change
> on a package may affect a dependent one.
> - `mix deps.get` makes sense to be executed only on the root packages if
> you have adopted a common deps path, since internal dependencies will be
> inherited from dependent packages.
> - In the main branch it makes sense to run the full suite on the
> complete workspace.
Check `Workspace` for more details. In order to visualize the graph of the current
workspace check the `workspace.graph` task.
## Command-line Options
#{CliOptions.docs(@options_schema, sort: true)}
## Filtering tasks
One of the key features of `workspace.run` is the flexibility regarding the projects
on which the task will be executed.
Let's see some examples:
# Run test on all workspace projects
$ mix workspace.run -t test
# Run test only on foo and bar
$ mix workspace.run -t test -p foo -p bar
# Run test on all projects excluding foo and bar
$ mix workspace.run -t test --exclude foo --exclude bar
# Run test on all affected projects
$ mix workspace.run -t test --affected
# Run test on all modified projects
$ mix workspace.run -t test --modified
# Run test only on top level projects
$ mix workspace.run -t test --only-roots
## Run partitioning
In big workspaces some CI steps may take a lot time. You can split the execution of
a task in multiple partitions in order to speed up this process. This is done by
setting the `--partitions` option and setting the `WORKSPACE_RUN_PARTITION` environment
variable to control the current partition.
For example to split the execution of `mix test` on all workspace projects into
4 partitions, you would use the following:
$ WORKSPACE_RUN_PARTITION=1 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=2 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=3 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=4 mix workspace.run -t run --partitions 4
The matching projects are sorted upfront and assigned to each partition in a round-robin
fashion.
## The `--env-var` option
Some tasks depend on environment variables. You can use the `--env-var` option to
set an environment variable only during a task's execution. This is particularly useful
in CI environments.
$ mix workspace.run -t compile --env-var MIX_ENV=compile
You can set multiple environment variables by setting the `--env-var` multiple times.
## Dry running a task
You can set the `--dry-run` option in order to dry run the task, e.g. to check the
sequence of mix tasks that will be invoked.
## Early stopping
You can set the `--early-stop` flag in order to immediately terminate the command if
the execution in any of the projects has failed.
## Allowing failures on some projects
On big codebases not all projects are of the same quality standards and some linter
tasks may fail on them. In such cases you may want to still run the linting tasks on
these projects but ignore their output status from the overall run task's status. In
such cases you can use the `--allow-failure` option:
# Run lint on all projects but ignore the output status of project foo
$ mix workspace.run -t lint --allow-failure foo
"""
use Mix.Task
@recursive true
import Workspace.Cli
@impl Mix.Task
def run(args) do
Mix.Task.reenable("workspace.run")
{opts, _args, extra} = CliOptions.parse!(args, @options_schema)
opts =
Keyword.update(opts, :allow_failure, [], fn projects ->
Enum.map(projects, &String.to_atom/1)
end)
opts
|> Mix.WorkspaceUtils.load_and_filter_workspace()
|> Workspace.projects()
|> filter_by_partition(opts[:partitions])
|> Enum.map(fn project ->
triggered_at = System.os_time(:millisecond)
result = run_in_project(project, opts, extra)
completed_at = System.os_time(:millisecond)
%{
project: project,
status: execution_status(result, allowed_to_fail?(project.app, opts[:allow_failure])),
triggered_at: triggered_at,
completed_at: completed_at
}
|> maybe_early_stop(opts[:early_stop])
end)
|> raise_if_any_task_failed()
end
defp filter_by_partition(projects, partitions) when partitions in [nil, 1], do: projects
defp filter_by_partition(projects, partitions) when partitions > 1 do
partition = System.get_env("WORKSPACE_RUN_PARTITION")
case partition && Integer.parse(partition) do
{partition, ""} when partition in 1..partitions ->
partition = partition - 1
for {project, index} <- Enum.with_index(Enum.sort_by(projects, & &1.app)),
rem(index, partitions) == partition,
do: project
_other ->
Mix.raise(
"The WORKSPACE_RUN_PARTITION environment variable must be set to an integer between " <>
"1..#{partitions} when the --partitions option is set, got: #{inspect(partition)}"
)
end
end
defp allowed_to_fail?(project, allowed_to_fail), do: project in allowed_to_fail
defp execution_status({:error, _reason}, true), do: :warn
defp execution_status({:error, _reason}, false), do: :error
defp execution_status(_status, _allowed_to_fail), do: :ok
defp run_in_project(%{skip: true} = project, options, _argv) do
if options[:verbose] do
log_with_title(
project_name(project, show_status: options[:show_status]),
highlight("skipped", [:bright, :yellow]),
prefix: :header
)
end
end
defp run_in_project(project, options, argv) do
task = options[:task]
task_args = [task | argv]
env = parse_environment_variables(options[:env_var] || [])
log_with_title(
project_name(project, show_status: options[:show_status]),
highlight("mix #{Enum.join(task_args, " ")}", :bright),
prefix: :header
)
if not options[:dry_run] do
run_task(project, task, argv, options, env)
end
end
defp parse_environment_variables(vars) do
Enum.map(vars, &parse_environment_variable/1)
end
defp parse_environment_variable(var) do
case String.split(var, "=") do
[name, value] when value != "" ->
{String.upcase(name) |> String.to_charlist(), String.to_charlist(value)}
other ->
Mix.raise(
"invalid environment variable definition, it should be of the form " <>
"ENV_VAR_NAME=value, got: #{other}"
)
end
end
defp run_task(project, task, argv, options, env) do
case options[:execution_mode] do
"process" ->
cmd(task, argv, project, env)
"in-project" ->
Mix.Project.in_project(
project.app,
project.path,
fn _mixfile ->
Mix.Task.run(task, argv)
end
)
other ->
Mix.raise(
"invalid execution mode #{other}, only `process` and `in-project` are supported"
)
end
end
defp cmd(task, argv, project, env) do
full_task = ~s'mix #{Enum.join([task | argv], " ")}'
[command | args] = enable_ansi(["mix", task | argv])
command = System.find_executable(command)
port =
Port.open({:spawn_executable, command}, [
:stream,
:hide,
:use_stdio,
:stderr_to_stdout,
:binary,
:exit_status,
args: args,
cd: project.path,
env: env
])
status_code = stream_output([args: args, project: project, task: full_task], port)
case status_code do
0 -> :ok
_other -> {:error, "#{full_task} failed in #{project.app}"}
end
end
# elixir tasks are not run in a TTY and will by default not print ANSI
# characters, We explicitely enable ANSI
# kudos to `ex_check`: https://github.com/karolsluszniak/ex_check
defp enable_ansi(["mix" | args]) do
erl_config = Application.app_dir(:workspace, ~w[priv enable_ansi.config])
["elixir", "--erl-config", erl_config, "-S", "mix" | args]
end
defp stream_output(meta, port) do
receive do
{^port, {:data, data}} ->
IO.write(data)
stream_output(meta, port)
{^port, {:exit_status, 0}} ->
0
{^port, {:exit_status, status}} ->
task = Keyword.get(meta, :task)
project = Keyword.get(meta, :project)
log([
highlight(inspect(project.app), [:bright, :red]),
" ",
highlight(task, :bright),
" failed with ",
highlight("#{status}", [:bright, :light_red])
])
status
end
end
defp maybe_early_stop(result, false), do: result
defp maybe_early_stop(result, true) do
case result[:status] do
:error ->
Mix.raise("--early-stop is set - terminating workspace.run")
_other ->
result
end
end
defp raise_if_any_task_failed(task_results) do
{_successful, warnings, failures} =
Enum.reduce(
task_results,
{[], [], []},
fn project, {successful, warnings, failures} ->
case project[:status] do
:error ->
{successful, warnings, [project | failures]}
:warn ->
{successful, [project | warnings], failures}
:ok ->
{[project | successful], warnings, failures}
end
end
)
names = fn projects -> Enum.map(projects, & &1.project.app) end
if length(warnings) > 0 do
Workspace.Cli.log([
:yellow,
"WARNING ",
:reset,
"task failed in #{length(warnings)} projects but the ",
:light_cyan,
"--alow-failure",
:reset,
" flag is set"
])
Workspace.Cli.log([" failed projects - ", :yellow, inspect(names.(warnings))])
end
if length(failures) > 0 do
Mix.raise("""
mix workspace.run failed - errors detected in #{length(failures)} executions
failed projects - #{inspect(names.(failures))}
""")
end
end
end