├── .tool-versions
├── apps
├── gust
│ ├── README.md
│ ├── lib
│ │ ├── gust
│ │ │ ├── mailer.ex
│ │ │ ├── dag
│ │ │ │ ├── cron.ex
│ │ │ │ ├── graph
│ │ │ │ │ └── cycle_dectection.ex
│ │ │ │ ├── error_parser.ex
│ │ │ │ ├── task_delayer.ex
│ │ │ │ ├── scheduler.ex
│ │ │ │ ├── logger.ex
│ │ │ │ ├── task_delayer
│ │ │ │ │ └── calculator.ex
│ │ │ │ ├── runner_supervisor.ex
│ │ │ │ ├── compiler.ex
│ │ │ │ ├── stage_runner_supervisor.ex
│ │ │ │ ├── loader.ex
│ │ │ │ ├── task_runner_supervisor.ex
│ │ │ │ ├── terminator.ex
│ │ │ │ ├── parser.ex
│ │ │ │ ├── terminator
│ │ │ │ │ └── process.ex
│ │ │ │ ├── runner_supervisor
│ │ │ │ │ └── dynamic_supervisor.ex
│ │ │ │ ├── stage_runner_supervisor
│ │ │ │ │ └── dynamic_supervisor.ex
│ │ │ │ ├── task_runner_supervisor
│ │ │ │ │ └── dynamic_supervisor.ex
│ │ │ │ ├── run_restarter.ex
│ │ │ │ ├── definition.ex
│ │ │ │ ├── compiler
│ │ │ │ │ └── code.ex
│ │ │ │ ├── scheduler
│ │ │ │ │ └── worker.ex
│ │ │ │ ├── stage_coordinator.ex
│ │ │ │ ├── logger
│ │ │ │ │ └── database.ex
│ │ │ │ ├── runner
│ │ │ │ │ ├── task_worker.ex
│ │ │ │ │ └── dag_worker.ex
│ │ │ │ ├── graph.ex
│ │ │ │ ├── stage_coordinator
│ │ │ │ │ └── retrying_runner.ex
│ │ │ │ ├── loader
│ │ │ │ │ └── worker.ex
│ │ │ │ ├── parser
│ │ │ │ │ └── file.ex
│ │ │ │ └── run_restarter
│ │ │ │ │ └── worker.ex
│ │ │ ├── repo.ex
│ │ │ ├── encrypted
│ │ │ │ └── binary.ex
│ │ │ ├── file_monitor.ex
│ │ │ ├── vault.ex
│ │ │ ├── flows
│ │ │ │ ├── log.ex
│ │ │ │ ├── dag.ex
│ │ │ │ ├── run.ex
│ │ │ │ ├── secret.ex
│ │ │ │ └── task.ex
│ │ │ ├── release.ex
│ │ │ ├── file_monitor
│ │ │ │ ├── system_fs.ex
│ │ │ │ └── worker.ex
│ │ │ ├── pub_sub.ex
│ │ │ └── dsl.ex
│ │ └── gust.ex
│ ├── priv
│ │ └── repo
│ │ │ ├── migrations
│ │ │ ├── .formatter.exs
│ │ │ ├── 20251031173208_add_task_error.exs
│ │ │ ├── 20250815190135_create_runs.exs
│ │ │ ├── 20250806203011_create_dags.exs
│ │ │ ├── 20251026200057_create_secrets.exs
│ │ │ ├── 20250930125654_create_logs.exs
│ │ │ └── 20250815205059_create_tasks.exs
│ │ │ └── seeds.exs
│ ├── .formatter.exs
│ ├── coveralls.json
│ ├── test
│ │ ├── flows
│ │ │ ├── run_test.exs
│ │ │ ├── dag_test.exs
│ │ │ └── secret_test.exs
│ │ ├── support
│ │ │ ├── runner
│ │ │ │ └── empty.ex
│ │ │ ├── fs_helpers.ex
│ │ │ ├── fixtures
│ │ │ │ └── flows_fixtures.ex
│ │ │ └── data_case.ex
│ │ ├── dag
│ │ │ ├── graph
│ │ │ │ └── cycle_dectection_test.exs
│ │ │ ├── task_delayer
│ │ │ │ └── calculator_test.exs
│ │ │ ├── error_parser_test.exs
│ │ │ ├── definition_test.exs
│ │ │ ├── runner_supervisor
│ │ │ │ └── dynamic_supervisor_test.exs
│ │ │ ├── stage_runner_supervisor
│ │ │ │ └── dynamic_supervisor_test.exs
│ │ │ ├── task_runner_supervisor
│ │ │ │ └── dynamic_supervisor_test.exs
│ │ │ ├── compiler
│ │ │ │ └── code_test.exs
│ │ │ ├── terminator
│ │ │ │ └── process_test.exs
│ │ │ ├── graph_test.exs
│ │ │ ├── scheduler
│ │ │ │ └── worker_test.exs
│ │ │ ├── logger
│ │ │ │ └── database_test.exs
│ │ │ └── runner
│ │ │ │ └── task_worker_test.exs
│ │ ├── pub_sub_test.exs
│ │ ├── test_helper.exs
│ │ └── dsl_test.exs
│ ├── .gitignore
│ └── mix.exs
└── gust_web
│ ├── priv
│ ├── static
│ │ ├── favicon.ico
│ │ ├── images
│ │ │ └── gust-logo.png
│ │ └── robots.txt
│ └── gettext
│ │ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ │ └── errors.pot
│ ├── .formatter.exs
│ ├── lib
│ ├── gust_web
│ │ ├── controllers
│ │ │ ├── page_controller.ex
│ │ │ ├── error_json.ex
│ │ │ └── error_html.ex
│ │ ├── mix
│ │ │ └── tasks
│ │ │ │ ├── gust.start.ex
│ │ │ │ ├── gust.dags.boot.ex
│ │ │ │ └── gust.ecto.migrate.ex
│ │ ├── mermaid.ex
│ │ ├── components
│ │ │ ├── layouts
│ │ │ │ └── root.html.heex
│ │ │ ├── dag_run_components.ex
│ │ │ └── layouts.ex
│ │ ├── live
│ │ │ ├── dag_live
│ │ │ │ ├── index.html.heex
│ │ │ │ └── index.ex
│ │ │ ├── breadcrumbs_component.ex
│ │ │ ├── run_live
│ │ │ │ ├── index.ex
│ │ │ │ └── index.html.heex
│ │ │ ├── secret_live
│ │ │ │ ├── index.ex
│ │ │ │ └── index.html.heex
│ │ │ └── dag_summary_component.ex
│ │ ├── gettext.ex
│ │ ├── application.ex
│ │ ├── endpoint.ex
│ │ ├── router.ex
│ │ └── telemetry.ex
│ └── gust_web.ex
│ ├── test
│ ├── gust_web
│ │ ├── controllers
│ │ │ ├── page_controller_test.exs
│ │ │ ├── error_json_test.exs
│ │ │ └── error_html_test.exs
│ │ ├── mermaid_test.exs
│ │ └── live
│ │ │ ├── breadcrumbs_live_component_test.exs
│ │ │ ├── run_live_test.exs
│ │ │ ├── dag_summary_live_component_test.exs
│ │ │ ├── secret_live_test.exs
│ │ │ └── dag_live_test.exs
│ ├── test_helper.exs
│ └── support
│ │ ├── conn_case.ex
│ │ └── live_component_tests.ex
│ ├── coveralls.json
│ ├── assets
│ ├── package.json
│ ├── tsconfig.json
│ ├── vendor
│ │ └── heroicons.js
│ └── js
│ │ └── app.js
│ ├── README.md
│ ├── .gitignore
│ └── mix.exs
├── .formatter.exs
├── Makefile
├── config
├── prod.exs
├── test.exs
├── config.exs
├── dev.exs
└── runtime.exs
├── .gitignore
├── LICENSE
├── .github
└── workflows
│ ├── publish.yml
│ └── test.yml
├── mix.exs
└── README.md
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.18.4
2 |
--------------------------------------------------------------------------------
/apps/gust/README.md:
--------------------------------------------------------------------------------
1 | # Gust
2 |
3 | **TODO: Add description**
4 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Mailer do
2 | use Swoosh.Mailer, otp_app: :gust
3 | end
4 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/apps/gust_web/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marciok/gust/HEAD/apps/gust_web/priv/static/favicon.ico
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/cron.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Cron do
2 | @moduledoc false
3 | use Quantum, otp_app: :gust
4 | end
5 |
--------------------------------------------------------------------------------
/apps/gust_web/priv/static/images/gust-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marciok/gust/HEAD/apps/gust_web/priv/static/images/gust-logo.png
--------------------------------------------------------------------------------
/apps/gust/lib/gust/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo do
2 | use Ecto.Repo,
3 | otp_app: :gust,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | plugins: [Phoenix.LiveView.HTMLFormatter],
3 | inputs: ["mix.exs", "config/*.exs"],
4 | subdirectories: ["apps/*"]
5 | ]
6 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/graph/cycle_dectection.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Graph.CycleDection do
2 | defexception message: "Possible cycle detected"
3 | end
4 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/encrypted/binary.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Encrypted.Binary do
2 | @moduledoc false
3 | use Cloak.Ecto.Binary, vault: Gust.Vault
4 | end
5 |
--------------------------------------------------------------------------------
/apps/gust_web/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | plugins: [Phoenix.LiveView.HTMLFormatter],
4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
5 | ]
6 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | dev:
3 | mix gust.start
4 |
5 | test:
6 | mix test
7 |
8 | test-cover:
9 | MIX_ENV=test mix coveralls.html --umbrella
10 |
11 | lint:
12 | mix lint
13 |
14 | console:
15 | iex -S mix
16 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.PageController do
2 | @moduledoc false
3 | use GustWeb, :controller
4 |
5 | def home(conn, _params) do
6 | redirect(conn, to: ~p"/dags")
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/apps/gust_web/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | User-agent: *
5 | Disallow: /
6 |
--------------------------------------------------------------------------------
/apps/gust/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :ecto_sql],
3 | subdirectories: ["priv/*/migrations"],
4 | plugins: [Phoenix.LiveView.HTMLFormatter],
5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
6 | ]
7 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/error_parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.ErrorParser do
2 | @moduledoc false
3 | def parse(error) do
4 | %{
5 | type: inspect(error.__struct__),
6 | message: Exception.message(error)
7 | }
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.PageControllerTest do
2 | use GustWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, ~p"/")
6 | assert redirected_to(conn) == ~p"/dags"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/apps/gust/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 98
4 | },
5 | "skip_files": [
6 | "test/support",
7 | "lib/gust.ex",
8 | "lib/gust/mailer.ex",
9 | "lib/gust/release.ex",
10 | "lib/gust/repo.ex"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20251031173208_add_task_error.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.AddTaskError do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tasks) do
6 | add :error, :jsonb, default: "{}", null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust/test/flows/run_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.RunTest do
2 | use ExUnit.Case, async: true
3 | alias Gust.Flows.Run
4 |
5 | test "rejects creation without dag_id" do
6 | changeset = Run.changeset(%Run{}, %{})
7 | refute changeset.valid?
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust/test/support/runner/empty.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Runner.Empty do
2 | @moduledoc false
3 | use GenServer
4 |
5 | def init(init_arg) do
6 | {:ok, init_arg}
7 | end
8 |
9 | def start_link(arg) do
10 | GenServer.start_link(__MODULE__, arg)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/graph/cycle_dectection_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.Graph.CycleDectectionTest do
2 | alias Gust.DAG.Graph.CycleDection
3 | use Gust.DataCase
4 |
5 | test "error message" do
6 | assert_raise(CycleDection, "Possible cycle detected", fn -> raise CycleDection end)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/task_delayer/calculator_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.TaskDelayer.CalculatorTest do
2 | alias Gust.DAG.TaskDelayer.Calculator
3 | use Gust.DataCase
4 |
5 | test "calc_delay/1" do
6 | attempt = 3
7 | assert 20_000 = Calculator.calc_delay(attempt)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust do
2 | @moduledoc """
3 | Gust keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/task_delayer.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.TaskDelayer do
2 | @moduledoc false
3 | @callback calc_delay(attempt :: integer()) :: integer()
4 |
5 | def calc_delay(attempt), do: impl().calc_delay(attempt)
6 | def impl, do: Application.get_env(:gust, :dag_task_delayer, Gust.DAG.TaskDelayer.Calculator)
7 | end
8 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/scheduler.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Scheduler do
2 | @moduledoc false
3 |
4 | @callback schedule(%{integer() => {:ok, Gust.DAG.Definition.t()} | {:error, term()}}) :: :ok
5 |
6 | def schedule(dag_defs), do: impl().schedule(dag_defs)
7 |
8 | defp impl, do: Application.get_env(:gust, :dag_scheduler)
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/mix/tasks/gust.start.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Gust.Start do
2 | @moduledoc false
3 | use Mix.Task
4 |
5 | # coveralls-ignore-start
6 | @impl Mix.Task
7 | def run(_args) do
8 | Mix.Task.run("gust.dags.boot")
9 | Mix.Task.run("phx.server")
10 | end
11 |
12 | # coveralls-ignore-stop
13 | end
14 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20250815190135_create_runs.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.CreateRuns do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:runs) do
6 | add :dag_id, references(:dags, on_delete: :delete_all), null: false
7 | add :status, :string
8 |
9 | timestamps(type: :utc_datetime)
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/logger.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Logger do
2 | @moduledoc false
3 | @callback set_task(task_id :: integer(), attempt :: integer()) :: nil
4 | @callback unset() :: nil
5 |
6 | def set_task(task_id, attempt), do: impl().set_task(task_id, attempt)
7 | def unset, do: impl().unset()
8 |
9 | def impl, do: Application.get_env(:gust, :dag_logger)
10 | end
11 |
--------------------------------------------------------------------------------
/apps/gust/test/support/fs_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.FSHelpers do
2 | @moduledoc false
3 | def make_rand_dir!(prefix) do
4 | base = System.tmp_dir!()
5 |
6 | uniq =
7 | "#{prefix}_#{System.monotonic_time()}_#{System.unique_integer([:positive, :monotonic])}"
8 |
9 | path = Path.join(base, uniq)
10 | File.mkdir_p!(path)
11 | path
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/mix/tasks/gust.dags.boot.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Gust.Dags.Boot do
2 | @moduledoc false
3 | use Mix.Task
4 | require Logger
5 |
6 | # coveralls-ignore-start
7 | @impl Mix.Task
8 | def run(_args) do
9 | Logger.info("Booting DAGs")
10 | Application.put_env(:gust, :boot_dag, true)
11 | end
12 |
13 | # coveralls-ignore-stop
14 | end
15 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/task_delayer/calculator.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.TaskDelayer.Calculator do
2 | @moduledoc false
3 | @behaviour Gust.DAG.TaskDelayer
4 |
5 | def calc_delay(attempt) do
6 | delay = 5_000
7 | exp_backoff(delay, attempt)
8 | end
9 |
10 | defp exp_backoff(delay, attempt) do
11 | (delay * :math.pow(2, attempt - 1)) |> round()
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/apps/gust_web/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 98
4 | },
5 | "skip_files": [
6 | "test/support",
7 | "lib/gust_web/telemetry.ex",
8 | "lib/gust_web.ex",
9 | "lib/gust_web/gettext.ex",
10 | "lib/gust_web/endpoint.ex",
11 | "lib/gust_web/components/core_components.ex",
12 | "lib/gust_web/application.ex"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/apps/gust_web/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "assets",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1"
7 | },
8 | "keywords": [],
9 | "author": "",
10 | "license": "ISC",
11 | "description": "",
12 | "dependencies": {
13 | "mermaid": "^11.12.0",
14 | "prismjs": "^1.30.0"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/mix/tasks/gust.ecto.migrate.ex:
--------------------------------------------------------------------------------
1 | defmodule Mix.Tasks.Gust.Ecto.Migrate do
2 | @moduledoc false
3 | require Logger
4 | use Mix.Task
5 |
6 | # coveralls-ignore-start
7 | @impl Mix.Task
8 | def run(_args) do
9 | Logger.info("Migrating Gust")
10 | Mix.Task.run("mix ecto.migrate --repo Gust.Repo")
11 | end
12 |
13 | # coveralls-ignore-stop
14 | end
15 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/runner_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.RunnerSupervisor do
2 | @moduledoc false
3 |
4 | @callback start_child(Gust.Flows.Run.t(), Gust.DAG.Definition.t()) ::
5 | Supervisor.on_start_child()
6 |
7 | def start_child(run, dag_def),
8 | do: impl().start_child(run, dag_def)
9 |
10 | defp impl,
11 | do: Application.get_env(:gust, :dag_runner_supervisor)
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/file_monitor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.FileMonitor do
2 | @moduledoc false
3 | @callback start_link(keyword()) :: GenServer.on_start()
4 | @callback watch(GenServer.server()) :: :ok
5 |
6 | def watch(server_pid), do: impl().watch(server_pid)
7 | def start_link(args), do: impl().start_link(args)
8 | defp impl, do: Application.get_env(:gust, :file_monitor, Gust.FileMonitor.SystemFs)
9 | end
10 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Gust.Repo.insert!(%Gust.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20250806203011_create_dags.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.CreateDags do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:dags) do
6 | add :name, :string, null: false
7 | add :enabled, :boolean, default: true, null: false
8 |
9 | timestamps(type: :utc_datetime)
10 | end
11 |
12 | create unique_index(:dags, [:name])
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/compiler.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Compiler do
2 | @moduledoc false
3 | @type dag_def :: Gust.DAG.Definition.t()
4 |
5 | @callback compile(dag_def) :: module()
6 | @callback purge(module()) :: nil
7 |
8 | def compile(dag_def), do: impl().compile(dag_def)
9 | def purge(mod), do: impl().purge(mod)
10 |
11 | defp impl, do: Application.get_env(:gust, :dag_compiler, Gust.DAG.Compiler.Code)
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.ErrorJSONTest do
2 | use GustWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert GustWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert GustWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/stage_runner_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.StageRunnerSupervisor do
2 | @moduledoc false
3 |
4 | @callback start_child(Gust.DAG.Definition.t(), [term()], term()) ::
5 | Supervisor.on_start_child()
6 |
7 | def start_child(dag_def, stage, run_id),
8 | do: impl().start_child(dag_def, stage, run_id)
9 |
10 | defp impl,
11 | do: Application.get_env(:gust, :dag_stage_runner_supervisor)
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/error_parser_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Dag.ErrorParser do
2 | use Gust.DataCase
3 | import Gust.DAG.ErrorParser
4 |
5 | test "parse/1 when result does not have a reason" do
6 | error_msg = "ops.. something went wrong."
7 | error = %Ecto.Query.CastError{message: error_msg}
8 |
9 | assert parse(error) == %{
10 | type: "Ecto.Query.CastError",
11 | message: error_msg
12 | }
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/loader.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Loader do
2 | @moduledoc false
3 |
4 | @callback get_definitions() :: %{term() => Gust.DAG.Definition.t()}
5 | @callback get_definition(term()) :: Gust.DAG.Definition.t()
6 |
7 | # coveralls-ignore-start
8 | def get_definitions, do: impl().get_definitions()
9 | def get_definition(dag_id), do: impl().get_definition(dag_id)
10 | defp impl, do: Application.get_env(:gust, :dag_loader)
11 | # coveralls-ignore-stop
12 | end
13 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/vault.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Vault do
2 | @moduledoc false
3 | use Cloak.Vault, otp_app: :gust
4 |
5 | @impl GenServer
6 | def init(config) do
7 | config =
8 | Keyword.put(config, :ciphers,
9 | default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: decode_env!()}
10 | )
11 |
12 | {:ok, config}
13 | end
14 |
15 | defp decode_env! do
16 | Application.get_env(:gust, :b64_secrets_cloak_key) |> Base.decode64!()
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20251026200057_create_secrets.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.CreateSecrets do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:secrets, primary_key: false) do
6 | add :id, :binary_id, primary_key: true
7 | add :name, :string, null: false
8 | add :value, :binary, null: false
9 | add :value_type, :string, null: false
10 |
11 | timestamps()
12 | end
13 |
14 | create unique_index(:secrets, [:name])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20250930125654_create_logs.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.CreateLogs do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:logs) do
6 | add :attempt, :integer
7 | add :level, :string
8 | add :content, :text
9 | add :timestamp, :utc_datetime_usec
10 | add :task_id, references(:tasks, on_delete: :delete_all)
11 |
12 | timestamps(type: :utc_datetime)
13 | end
14 |
15 | create index(:logs, [:task_id])
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.ErrorHTMLTest do
2 | use GustWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template, only: [render_to_string: 4]
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(GustWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(GustWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/task_runner_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.TaskRunnerSupervisor do
2 | @moduledoc false
3 |
4 | @callback start_child(Gust.Flows.Task.t(), module(), pid(), term()) ::
5 | Supervisor.on_start_child()
6 |
7 | def start_child(task, mod, stage_pid, opts), do: impl().start_child(task, mod, stage_pid, opts)
8 |
9 | defp impl,
10 | do:
11 | Application.get_env(
12 | :gust,
13 | :dag_task_runner_supervisor,
14 | Gust.DAG.TaskRunnerSupervisor.DynamicSupervisor
15 | )
16 | end
17 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/terminator.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Terminator do
2 | @moduledoc false
3 | @callback kill_task(task :: Gust.Flows.Task.t(), status :: atom()) :: any()
4 | @callback cancel_timer(task :: Gust.Flows.Task.t(), status :: atom()) :: any()
5 |
6 | # coveralls-ignore-start
7 | def kill_task(task, status), do: impl().kill_task(task, status)
8 | def cancel_timer(task, status), do: impl().cancel_timer(task, status)
9 | def impl, do: Application.get_env(:gust, :dag_terminator, Gust.DAG.Terminator.Process)
10 | # coveralls-ignore-stop
11 | end
12 |
--------------------------------------------------------------------------------
/apps/gust/priv/repo/migrations/20250815205059_create_tasks.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Repo.Migrations.CreateTasks do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:tasks) do
6 | add :name, :string
7 | add :status, :string
8 | add :result, :jsonb, default: "{}", null: false
9 | add :run_id, references(:runs, on_delete: :delete_all), null: false
10 | add :attempt, :integer, default: 1
11 |
12 | timestamps(type: :utc_datetime)
13 | end
14 |
15 | create index(:tasks, [:run_id])
16 | create index(:runs, [:dag_id])
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/flows/log.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.Log do
2 | @moduledoc false
3 |
4 | use Ecto.Schema
5 | import Ecto.Changeset
6 |
7 | schema "logs" do
8 | field :level, :string
9 | field :attempt, :integer
10 | field :content, :string
11 | field :timestamp, :utc_datetime_usec
12 | belongs_to :task, Gust.Flows.Task
13 |
14 | timestamps(type: :utc_datetime)
15 | end
16 |
17 | @doc false
18 | def changeset(log, attrs) do
19 | log
20 | |> cast(attrs, [:task_id, :attempt, :level, :content])
21 | |> validate_required([:task_id, :attempt, :level, :content])
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Release do
2 | @moduledoc false
3 | @app :gust
4 |
5 | def migrate do
6 | load_app()
7 |
8 | for repo <- repos() do
9 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
10 | end
11 | end
12 |
13 | def rollback(repo, version) do
14 | load_app()
15 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
16 | end
17 |
18 | defp repos do
19 | Application.fetch_env!(@app, :ecto_repos)
20 | end
21 |
22 | defp load_app do
23 | Application.load(@app)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/mermaid.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.Mermaid do
2 | @moduledoc false
3 | def chart(tasks) do
4 | tasks
5 | |> Enum.reduce("flowchart LR\n ", fn {name, %{upstream: upstream}}, flow_description ->
6 | upstream = upstream |> MapSet.to_list()
7 | lines = name |> build_lines(upstream)
8 |
9 | "#{flow_description}#{lines}"
10 | end)
11 | end
12 |
13 | defp build_lines(name, []) do
14 | "\n#{name}"
15 | end
16 |
17 | defp build_lines(name, upstream) do
18 | upstream
19 | |> Enum.reduce("", fn upstream_name, line ->
20 | "#{line}\n#{upstream_name} --> #{name}"
21 | end)
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Parser do
2 | @moduledoc false
3 |
4 | @callback parse(file_path :: String.t()) ::
5 | {:ok, module()} | {:error, term()}
6 |
7 | @callback parse_folder(folder :: String.t()) ::
8 | [{String.t(), {:ok, module()} | {:error, term()}}]
9 |
10 | @callback maybe_ex_file(path :: String.t()) :: String.t() | nil
11 |
12 | def parse(file_path), do: impl().parse(file_path)
13 | def parse_folder(folder), do: impl().parse_folder(folder)
14 | def maybe_ex_file(path), do: impl().maybe_ex_file(path)
15 |
16 | defp impl, do: Application.get_env(:gust, :dag_parser, Gust.DAG.Parser.File)
17 | end
18 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title default="Gust" suffix=" · GustFlow Framework">
8 | {assigns[:page_title]}
9 |
10 |
11 |
13 |
14 |
15 | {@inner_content}
16 |
17 |
18 |
--------------------------------------------------------------------------------
/apps/gust_web/README.md:
--------------------------------------------------------------------------------
1 | # GustWeb
2 |
3 | To start your Phoenix server:
4 |
5 | * Run `mix setup` to install and setup dependencies
6 | * Start Phoenix endpoint with `mix phx.server`
7 |
8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
9 |
10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
11 |
12 | ## Learn more
13 |
14 | * Official website: https://www.phoenixframework.org/
15 | * Guides: https://hexdocs.pm/phoenix/overview.html
16 | * Docs: https://hexdocs.pm/phoenix
17 | * Forum: https://elixirforum.com/c/phoenix-forum
18 | * Source: https://github.com/phoenixframework/phoenix
19 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/terminator/process.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Terminator.Process do
2 | @moduledoc false
3 | @behaviour Gust.DAG.Terminator
4 |
5 | alias Gust.Registry, as: GustReg
6 |
7 | def kill_task(task, status) do
8 | stage_pid = lookup("stage_run_#{task.run_id}")
9 | task_pid = lookup("task_#{task.id}")
10 |
11 | true = Process.exit(task_pid, :kill)
12 |
13 | send(stage_pid, {:task_result, nil, task.id, status})
14 | end
15 |
16 | def cancel_timer(task, status) do
17 | stage_pid = lookup("stage_run_#{task.run_id}")
18 | send(stage_pid, {:cancel_timer, task.id, status})
19 | end
20 |
21 | defp lookup(key) do
22 | [{pid, _val}] = Registry.lookup(GustReg, key)
23 | pid
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/apps/gust/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | # Ignore package tarball (built via "mix hex.build").
26 | gust-*.tar
27 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.ErrorJSON do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on JSON requests.
4 |
5 | See config/config.exs.
6 | """
7 |
8 | # If you want to customize a particular status code,
9 | # you may add your own clauses, such as:
10 | #
11 | # def render("500.json", _assigns) do
12 | # %{errors: %{detail: "Internal Server Error"}}
13 | # end
14 |
15 | # By default, Phoenix returns the status message from
16 | # the template name. For example, "404.json" becomes
17 | # "Not Found".
18 | def render(template, _assigns) do
19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/runner_supervisor/dynamic_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.RunnerSupervisor.DynamicSupervisor do
2 | @moduledoc false
3 | use DynamicSupervisor
4 | @behaviour Gust.DAG.RunnerSupervisor
5 |
6 | def start_link(init_arg) do
7 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
8 | end
9 |
10 | @impl true
11 | def init(_init_arg) do
12 | DynamicSupervisor.init(strategy: :one_for_one)
13 | end
14 |
15 | @impl true
16 | def start_child(run, dag_def) do
17 | spec = {runner(), %{run: run, dag_def: dag_def}}
18 | DynamicSupervisor.start_child(__MODULE__, spec)
19 | end
20 |
21 | def runner do
22 | Application.get_env(:gust, :dag_runner, Gust.DAG.Runner.DAGWorker)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/dag_live/index.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | {dag.name} has syntax errors.
9 |
10 |
11 |
12 |
17 | <.live_component
18 | module={GustWeb.DagSummaryComponent}
19 | dag={dag}
20 | dag_def={dag_def}
21 | id={dag.id}
22 | />
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/stage_runner_supervisor/dynamic_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.StageRunnerSupervisor.DynamicSupervisor do
2 | @moduledoc false
3 | alias Gust.DAG.StageRunnerSupervisor
4 | @behaviour StageRunnerSupervisor
5 |
6 | use DynamicSupervisor
7 |
8 | def start_link(init_arg) do
9 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
10 | end
11 |
12 | @impl true
13 | def init(_init_arg) do
14 | DynamicSupervisor.init(strategy: :one_for_one)
15 | end
16 |
17 | @impl true
18 | def start_child(dag_def, stage, run_id) do
19 | spec = {runner(), %{stage: stage, dag_def: dag_def, run_id: run_id}}
20 | DynamicSupervisor.start_child(__MODULE__, spec)
21 | end
22 |
23 | def runner do
24 | Application.get_env(:gust, :dag_stage_runner)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/task_runner_supervisor/dynamic_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.TaskRunnerSupervisor.DynamicSupervisor do
2 | @moduledoc false
3 |
4 | @behaviour Gust.DAG.TaskRunnerSupervisor
5 | use DynamicSupervisor
6 |
7 | def start_link(init_arg) do
8 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
9 | end
10 |
11 | @impl true
12 | def init(_init_arg) do
13 | DynamicSupervisor.init(strategy: :one_for_one)
14 | end
15 |
16 | @impl true
17 | def start_child(task, mod, stage_pid, opts) do
18 | spec = {runner(), %{task: task, mod: mod, stage_pid: stage_pid, opts: opts}}
19 | DynamicSupervisor.start_child(__MODULE__, spec)
20 | end
21 |
22 | def runner do
23 | Application.get_env(:gust, :dag_task_runner, Gust.DAG.Runner.TaskWorker)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.ErrorHTML do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on HTML requests.
4 |
5 | See config/config.exs.
6 | """
7 | use GustWeb, :html
8 |
9 | # If you want to customize your error pages,
10 | # uncomment the embed_templates/1 call below
11 | # and add pages to the error directory:
12 | #
13 | # * lib/gust_web/controllers/error_html/404.html.heex
14 | # * lib/gust_web/controllers/error_html/500.html.heex
15 | #
16 | # embed_templates "error_html/*"
17 |
18 | # The default is to render a plain text page based on
19 | # the template name. For example, "404.html" becomes
20 | # "Not Found".
21 | def render(template, _assigns) do
22 | Phoenix.Controller.status_message_from_template(template)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/file_monitor/system_fs.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.FileMonitor.SystemFs do
2 | @moduledoc false
3 | @behaviour Gust.FileMonitor
4 |
5 | # Note: To avoid compile-time warnings in environments where `:file_system` is not present
6 | # (e.g., `:prod`), we use `apply/3` instead of direct function calls.
7 | #
8 | # This ensures the compiler does not resolve `FileSystem.start_link/1` or
9 | # `FileSystem.subscribe/1` unless the module is actually loaded at runtime.
10 |
11 | # coveralls-ignore-start
12 | @impl true
13 | # credo:disable-for-next-line Credo.Check.Refactor.Apply
14 | def start_link(opts), do: apply(FileSystem, :start_link, [opts])
15 |
16 | @impl true
17 | # credo:disable-for-next-line Credo.Check.Refactor.Apply
18 | def watch(server), do: apply(FileSystem, :subscribe, [server])
19 |
20 | # coveralls-ignore-stop
21 | end
22 |
--------------------------------------------------------------------------------
/apps/gust_web/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | Application.ensure_all_started(:mox)
2 |
3 | Mox.defmock(GustWeb.DAGLoaderMock, for: Gust.DAG.Loader)
4 | Mox.defmock(GustWeb.DAGParserMock, for: Gust.DAG.Parser)
5 | Mox.defmock(GustWeb.DAGRunnerSupervisorMock, for: Gust.DAG.RunnerSupervisor)
6 | Mox.defmock(GustWeb.DAGTerminatorMock, for: Gust.DAG.Terminator)
7 | Mox.defmock(GustWeb.DAGRunRestarterMock, for: Gust.DAG.RunRestarter)
8 |
9 | Application.put_env(:gust, :dag_parser, GustWeb.DAGParserMock)
10 | Application.put_env(:gust, :dag_runner_supervisor, GustWeb.DAGRunnerSupervisorMock)
11 | Application.put_env(:gust, :dag_loader, GustWeb.DAGLoaderMock)
12 | Application.put_env(:gust, :dag_run_restarter, GustWeb.DAGRunRestarterMock)
13 | Application.put_env(:gust, :dag_terminator, GustWeb.DAGTerminatorMock)
14 |
15 | ExUnit.start()
16 | Ecto.Adapters.SQL.Sandbox.mode(Gust.Repo, :manual)
17 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/definition_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.DefinitionTest do
2 | alias Gust.DAG.Definition
3 | use Gust.DataCase
4 |
5 | test "fields are present" do
6 | dfn = %Definition{}
7 | assert dfn.name == ""
8 | assert dfn.mod == nil
9 | assert dfn.task_list == []
10 | assert dfn.stages == []
11 | assert dfn.tasks == %{}
12 | assert dfn.error == %{}
13 | assert dfn.messages == []
14 | assert dfn.file_path == ""
15 | assert dfn.options == []
16 |
17 | assert Map.keys(dfn) |> Enum.sort() ==
18 | [
19 | :__struct__,
20 | :error,
21 | :file_path,
22 | :messages,
23 | :mod,
24 | :name,
25 | :options,
26 | :stages,
27 | :task_list,
28 | :tasks
29 | ]
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix phx.digest` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :gust_web, GustWeb.Endpoint,
9 | url: [host: "example.com", port: 80],
10 | cache_static_manifest: "priv/static/cache_manifest.json"
11 |
12 | # Configures Swoosh API Client
13 | config :swoosh, :api_client, Swoosh.ApiClient.Req
14 |
15 | # Disable Swoosh Local Memory Storage
16 | config :swoosh, local: false
17 |
18 | # Do not print debug messages in production
19 | config :logger, level: :info
20 |
21 | config :gust_web, basic_auth: true
22 |
23 | # Runtime production configuration, including reading
24 | # of environment variables, is done on config/runtime.exs.
25 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/run_restarter.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.RunRestarter do
2 | @moduledoc false
3 |
4 | @callback start_dag(integer()) :: term()
5 | @callback restart_run(Gust.Flows.Run.t()) :: :ok
6 | @callback restart_task(map(), Gust.Flows.Task.t()) :: :ok
7 | @callback restart_dags(%{integer() => {:ok, Gust.DAG.Definition.t()} | {:error, term()}}) :: :ok
8 | @callback restart_enqueued(integer()) :: :ok
9 |
10 | # coveralls-ignore-start
11 | def start_dag(dag_id), do: impl().start_dag(dag_id)
12 | def restart_run(run), do: impl().restart_run(run)
13 | def restart_task(graph, task), do: impl().restart_task(graph, task)
14 | def restart_dags(dags), do: impl().restart_dags(dags)
15 | def restart_enqueued(dag), do: impl().restart_enqueued(dag)
16 |
17 | defp impl, do: Application.get_env(:gust, :dag_run_restarter, Gust.DAG.RunRestarter.Worker)
18 | # coveralls-ignore-stop
19 | end
20 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/definition.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Definition do
2 | @moduledoc false
3 | defstruct name: "",
4 | mod: nil,
5 | error: %{},
6 | messages: [],
7 | task_list: [],
8 | stages: [],
9 | tasks: %{},
10 | file_path: "",
11 | options: Keyword.new()
12 |
13 | @type t :: %__MODULE__{
14 | name: String.t(),
15 | mod: module() | nil,
16 | error: map(),
17 | messages: list(),
18 | task_list: list(),
19 | stages: list(),
20 | tasks: map(),
21 | file_path: String.t(),
22 | options: keyword()
23 | }
24 |
25 | @doc """
26 | Returns `true` if the given DAG definition has any errors, by checking that the `error` map is non-empty.
27 | """
28 | def empty_errors?(%__MODULE__{error: error}), do: map_size(error) == 0
29 | end
30 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
6 | that you can use in your application. To use this Gettext backend module,
7 | call `use Gettext` and pass it as an option:
8 |
9 | use Gettext, backend: GustWeb.Gettext
10 |
11 | # Simple translation
12 | gettext("Here is the string to translate")
13 |
14 | # Plural translation
15 | ngettext("Here is the string to translate",
16 | "Here are the strings to translate",
17 | 3)
18 |
19 | # Domain-based translation
20 | dgettext("errors", "Here is the error message to translate")
21 |
22 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
23 | """
24 | use Gettext.Backend, otp_app: :gust_web
25 | end
26 |
--------------------------------------------------------------------------------
/apps/gust/test/flows/dag_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.DagTest do
2 | use ExUnit.Case, async: true
3 | alias Gust.Flows.Dag
4 |
5 | describe "name format validation" do
6 | test "accepts valid names" do
7 | valid_names = ["some_name", "dag1", "abc_123"]
8 |
9 | for name <- valid_names do
10 | changeset = Dag.changeset(%Dag{}, %{name: name})
11 |
12 | assert changeset.valid?,
13 | "Expected #{name} to be valid, but got errors: #{inspect(changeset.errors)}"
14 | end
15 | end
16 |
17 | test "rejects invalid names" do
18 | invalid_names = ["SomeName", "some name", "some-name", "some$name", ""]
19 |
20 | for name <- invalid_names do
21 | changeset = Dag.changeset(%Dag{}, %{name: name})
22 |
23 | refute changeset.valid?,
24 | "Expected #{name} to be invalid"
25 |
26 | assert {:name, _} = hd(changeset.errors), "Expected error on :name for #{name}"
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/flows/dag.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.Dag do
2 | @moduledoc false
3 | use Ecto.Schema
4 | import Ecto.Changeset
5 |
6 | schema "dags" do
7 | field :name, :string
8 | field :enabled, :boolean, default: true
9 | has_many :runs, Gust.Flows.Run
10 |
11 | timestamps(type: :utc_datetime)
12 | end
13 |
14 | @type t :: %__MODULE__{
15 | id: integer() | nil,
16 | name: String.t() | nil,
17 | enabled: boolean(),
18 | runs: [Gust.Flows.Run.t()] | Ecto.Association.NotLoaded.t(),
19 | inserted_at: DateTime.t() | nil,
20 | updated_at: DateTime.t() | nil
21 | }
22 |
23 | @doc false
24 | def changeset(dag, attrs) do
25 | dag
26 | |> cast(attrs, [:name, :enabled])
27 | |> validate_required([:name])
28 | |> validate_format(:name, ~r/^[a-z0-9_]+$/,
29 | message: "must be lowercase, no spaces, only letters, numbers, and underscores"
30 | )
31 | |> unique_constraint(:name)
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/apps/gust_web/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | # Ignore package tarball (built via "mix hex.build").
26 | gust_web-*.tar
27 |
28 | # Ignore assets that are produced by build tools.
29 | /priv/static/assets/
30 |
31 | # Ignore digested assets cache.
32 | /priv/static/cache_manifest.json
33 |
34 | # In case you use Node.js/npm, you want to ignore these.
35 | npm-debug.log
36 | /assets/node_modules/
37 |
38 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/application.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | GustWeb.Telemetry,
12 | # Start a worker by calling: GustWeb.Worker.start_link(arg)
13 | # {GustWeb.Worker, arg},
14 | # Start to serve requests, typically the last entry
15 | GustWeb.Endpoint
16 | ]
17 |
18 | # See https://hexdocs.pm/elixir/Supervisor.html
19 | # for other strategies and supported options
20 | opts = [strategy: :one_for_one, name: GustWeb.Supervisor]
21 | Supervisor.start_link(children, opts)
22 | end
23 |
24 | # Tell Phoenix to update the endpoint configuration
25 | # whenever the application is updated.
26 | @impl true
27 | def config_change(changed, _new, removed) do
28 | GustWeb.Endpoint.config_change(changed, removed)
29 | :ok
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | .DS_Store
26 |
27 | dags/
28 |
29 | .envrc
30 | .env
31 | .env.test
32 | .env.dev
33 |
34 | # Built assets (compiled/digested)
35 | priv/static/assets/
36 | priv/static/cache_manifest.json
37 | apps/gust_web/priv/static/assets/
38 | apps/gust_web/priv/static/cache_manifest.json
39 |
40 | robots-*.txt.gz
41 | robots-*.txt
42 |
43 | favicon-*.ico
44 | robots-*.txt
45 | gust-logo-*.png
46 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/runner_supervisor/dynamic_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.RunnerSupervisor.DynamicSupervisorTest do
2 | use Gust.DataCase, async: true
3 | import Gust.FlowsFixtures
4 | alias Gust.DAG.Runner
5 | alias Gust.DAG.RunnerSupervisor.DynamicSupervisor, as: RunnerSupervisor
6 |
7 | test "start_child/3" do
8 | mod = MyPlainDag
9 |
10 | dag_def = %Gust.DAG.Definition{
11 | mod: mod,
12 | stages: [["sublime"]]
13 | }
14 |
15 | dag = dag_fixture()
16 | run = run_fixture(%{dag_id: dag.id})
17 | runner = Runner.Empty
18 |
19 | old = Application.get_env(:gust, :dag_runner)
20 | Application.put_env(:gust, :dag_runner, runner)
21 | on_exit(fn -> Application.put_env(:gust, :dag_runner, old) end)
22 |
23 | start_supervised!(RunnerSupervisor)
24 |
25 | {:ok, runner_pid} =
26 | RunnerSupervisor.start_child(run, dag_def)
27 |
28 | assert Process.alive?(runner_pid)
29 |
30 | assert [{_id, ^runner_pid, :worker, [^runner]}] =
31 | DynamicSupervisor.which_children(RunnerSupervisor)
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/compiler/code.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Compiler.Code do
2 | @moduledoc false
3 | @behaviour Gust.DAG.Compiler
4 |
5 | def compile(dag_def) do
6 | file_path = dag_def.file_path
7 | {:ok, ast} = Code.string_to_quoted(File.read!(file_path))
8 |
9 | runtime_mod = Module.concat(["Gust", "Runner", "#{dag_def.mod}_#{random_udid()}"])
10 | dag_ast = patch_module(ast, runtime_mod)
11 |
12 | {dag_module, _} = Code.compile_quoted(dag_ast, file_path) |> List.first()
13 | dag_module
14 | end
15 |
16 | def purge(mod) do
17 | :code.purge(mod)
18 | :code.delete(mod)
19 | end
20 |
21 | defp random_udid do
22 | timestamp = :os.system_time(:microsecond)
23 | random = :crypto.strong_rand_bytes(4) |> Base.encode16()
24 | "#{timestamp}-#{random}"
25 | end
26 |
27 | defp patch_module(ast, runtime_mod) do
28 | Macro.postwalk(ast, fn
29 | {:defmodule, meta, [_module_ast_node, block]} ->
30 | {:defmodule, meta, [Module.concat([runtime_mod]), block]}
31 |
32 | node ->
33 | node
34 | end)
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/stage_runner_supervisor/dynamic_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.StageRunnerSupervisor.DynamicSupervisorTest do
2 | use Gust.DataCase, async: true
3 | alias Gust.DAG.Runner
4 | alias Gust.DAG.StageRunnerSupervisor.DynamicSupervisor, as: StageRunnerSupervisor
5 |
6 | test "start_child/3" do
7 | mod = MyPlainDag
8 |
9 | dag_def = %Gust.DAG.Definition{
10 | mod: mod,
11 | stages: [["sublime"]]
12 | }
13 |
14 | runner = Runner.Empty
15 | old = Application.get_env(:gust, :dag_stage_runner)
16 | Application.put_env(:gust, :dag_stage_runner, runner)
17 |
18 | on_exit(fn -> Application.put_env(:gust, :dag_stage_runner, old) end)
19 |
20 | start_supervised!(StageRunnerSupervisor)
21 | task_id = "123"
22 | run_id = "321"
23 |
24 | {:ok, runner_pid} =
25 | StageRunnerSupervisor.start_child(dag_def, [task_id], run_id)
26 |
27 | assert Process.alive?(runner_pid)
28 |
29 | assert [{_id, ^runner_pid, :worker, [^runner]}] =
30 | DynamicSupervisor.which_children(StageRunnerSupervisor)
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/apps/gust_web/assets/tsconfig.json:
--------------------------------------------------------------------------------
1 | // This file is needed on most editors to enable the intelligent autocompletion
2 | // of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
3 | //
4 | // Note: This file assumes a basic esbuild setup without node_modules.
5 | // We include a generic paths alias to deps to mimic how esbuild resolves
6 | // the Phoenix and LiveView JavaScript assets.
7 | // If you have a package.json in your project, you should remove the
8 | // paths configuration and instead add the phoenix dependencies to the
9 | // dependencies section of your package.json:
10 | //
11 | // {
12 | // ...
13 | // "dependencies": {
14 | // ...,
15 | // "phoenix": "../deps/phoenix",
16 | // "phoenix_html": "../deps/phoenix_html",
17 | // "phoenix_live_view": "../deps/phoenix_live_view"
18 | // }
19 | // }
20 | //
21 | // Feel free to adjust this configuration however you need.
22 | {
23 | "compilerOptions": {
24 | "baseUrl": ".",
25 | "paths": {
26 | "*": ["../deps/*"]
27 | },
28 | "allowJs": true,
29 | "noEmit": true
30 | },
31 | "include": ["js/**/*"]
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Marcio Klepacz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/gust/test/support/fixtures/flows_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.FlowsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Gust.Flows` context.
5 | """
6 |
7 | def secret_fixture(attrs \\ %{}) do
8 | {:ok, secret} =
9 | attrs
10 | |> Enum.into(%{
11 | name: "SOME_NAME",
12 | value: "some value",
13 | value_type: :string
14 | })
15 | |> Gust.Flows.create_secret()
16 |
17 | secret
18 | end
19 |
20 | def dag_fixture(attrs \\ %{}) do
21 | {:ok, dag} =
22 | attrs
23 | |> Enum.into(%{
24 | name: "some_name"
25 | })
26 | |> Gust.Flows.create_dag()
27 |
28 | dag
29 | end
30 |
31 | def run_fixture(attrs \\ %{}) do
32 | {:ok, run} =
33 | Gust.Flows.create_test_run(attrs)
34 |
35 | run
36 | end
37 |
38 | def task_fixture(attrs \\ %{}) do
39 | {:ok, task} =
40 | Gust.Flows.create_test_task(attrs)
41 |
42 | task
43 | end
44 |
45 | def log_fixture(attrs \\ %{}) do
46 | {:ok, log} =
47 | Gust.Flows.create_log(attrs)
48 |
49 | log
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/task_runner_supervisor/dynamic_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.TaskRunnerSupervisor.DynamicSupervisorTest do
2 | use Gust.DataCase, async: true
3 | import Gust.FlowsFixtures
4 | alias Gust.DAG.Runner
5 | alias Gust.DAG.TaskRunnerSupervisor.DynamicSupervisor, as: TaskRunnerSupervisor
6 |
7 | test "start_child/4" do
8 | mod = MyPlainDag
9 | dag = dag_fixture()
10 | run = run_fixture(%{dag_id: dag.id})
11 | task = task_fixture(%{run_id: run.id, name: "green_day"})
12 | runner = Runner.Empty
13 |
14 | old = Application.get_env(:gust, :dag_task_runner)
15 | Application.put_env(:gust, :dag_task_runner, runner)
16 | on_exit(fn -> Application.put_env(:gust, :dag_task_runner, old) end)
17 |
18 | start_supervised!(TaskRunnerSupervisor)
19 |
20 | stage_pid = spawn(fn -> Process.sleep(100) end)
21 |
22 | {:ok, runner_pid} =
23 | TaskRunnerSupervisor.start_child(task, mod, stage_pid, %{})
24 |
25 | assert Process.alive?(runner_pid)
26 |
27 | assert [{_id, ^runner_pid, :worker, [^runner]}] =
28 | DynamicSupervisor.which_children(TaskRunnerSupervisor)
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/breadcrumbs_component.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.BreadcrumbsComponent do
2 | @moduledoc false
3 | use GustWeb, :live_component
4 |
5 | def render(assigns) do
6 | ~H"""
7 |
8 |
9 | -
10 | <.link id="dags-link" navigate={~p"/dags"}>
11 | DAGs
12 |
13 |
14 | -
15 | <.link id="dag-runs-link" navigate={~p"/dags/#{@dag_def.name}/dashboard"}>
16 | {@dag_def.name}
17 |
18 |
19 | -
20 | <.link id="dag-run-link" navigate={~p"/dags/#{@dag_def.name}/dashboard?run_id=#{@run.id}"}>
21 | {@run.id}
22 |
23 |
24 | -
25 | <.link
26 | id="dag-run-task-link"
27 | navigate={~p"/dags/#{@dag_def.name}/dashboard?run_id=#{@run.id}&task_name=#{@task.name}"}
28 | >
29 | {@task.name}
30 |
31 |
32 |
33 |
34 | """
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/apps/gust_web/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use GustWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint GustWeb.Endpoint
24 |
25 | use GustWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import GustWeb.ConnCase
31 | end
32 | end
33 |
34 | setup tags do
35 | Gust.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/scheduler/worker.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Scheduler.Worker do
2 | @moduledoc false
3 | @behaviour Gust.DAG.Scheduler
4 |
5 | import Crontab.CronExpression
6 | alias Gust.DAG.Cron
7 | alias Gust.DAG.Definition
8 | use GenServer
9 | alias Quantum.Job, as: QJob
10 |
11 | def init(args) do
12 | {:ok, args}
13 | end
14 |
15 | def start_link(args) do
16 | GenServer.start_link(__MODULE__, args, name: __MODULE__)
17 | end
18 |
19 | def schedule(dag_defs) do
20 | GenServer.call(__MODULE__, {:load_dags, dag_defs})
21 | end
22 |
23 | def handle_call({:load_dags, dag_defs}, _from, state) do
24 | jobs =
25 | for {dag_id, {:ok, dag_def}} <- dag_defs,
26 | schedule = dag_def.options[:schedule],
27 | schedule != nil,
28 | Definition.empty_errors?(dag_def) do
29 | add_dag_job(dag_def, dag_id)
30 | end
31 |
32 | {:reply, jobs, state}
33 | end
34 |
35 | def add_dag_job(dag_def, dag_id) do
36 | schedule = dag_def.options[:schedule]
37 |
38 | Cron.new_job()
39 | |> QJob.set_name(String.to_atom(dag_def.name))
40 | |> QJob.set_schedule(~e[#{schedule}])
41 | |> QJob.set_task({Gust.DAG.RunRestarter, :start_dag, [dag_id]})
42 | |> Cron.add_job()
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/flows/run.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.Run do
2 | @moduledoc false
3 |
4 | use Ecto.Schema
5 | import Ecto.Changeset
6 |
7 | schema "runs" do
8 | belongs_to :dag, Gust.Flows.Dag
9 |
10 | field :status, Ecto.Enum,
11 | values: [:created, :running, :succeeded, :failed, :retrying, :enqueued],
12 | default: :created
13 |
14 | has_many :tasks, Gust.Flows.Task
15 |
16 | timestamps(type: :utc_datetime)
17 | end
18 |
19 | @type t :: %__MODULE__{
20 | id: integer() | nil,
21 | dag_id: integer() | nil,
22 | status: :created | :running | :succeeded | :failed | :retrying | :enqueued,
23 | tasks: [Gust.Flows.Task.t()] | Ecto.Association.NotLoaded.t(),
24 | dag: Gust.Flows.Dag.t() | Ecto.Association.NotLoaded.t(),
25 | inserted_at: DateTime.t() | nil,
26 | updated_at: DateTime.t() | nil
27 | }
28 |
29 | @doc false
30 | def changeset(run, attrs) do
31 | run
32 | |> cast(attrs, [:dag_id, :status])
33 | |> validate_required([:dag_id, :status])
34 | end
35 |
36 | @doc false
37 | def test_changeset(run, attrs) do
38 | run
39 | |> cast(attrs, [:dag_id, :status, :inserted_at])
40 | |> validate_required([:dag_id, :status])
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/compiler/code_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.Compiler.CodeTest do
2 | alias DAG.Compiler
3 | use Gust.DataCase
4 | alias Gust.DAG.Compiler
5 | alias Gust.DAG.Definition
6 | import Gust.FSHelpers
7 |
8 | @original_mod_name "TheOffspring"
9 |
10 | setup do
11 | content = """
12 | defmodule #{@original_mod_name} do
13 |
14 | end
15 | """
16 |
17 | dir = make_rand_dir!("dags")
18 | dag_name = "the_offspring"
19 | file_path = "#{dir}/#{dag_name}.ex"
20 | File.write!(file_path, content)
21 |
22 | dag_def = %Definition{
23 | file_path: file_path,
24 | mod: Module.concat([@original_mod_name])
25 | }
26 |
27 | updated_mod = Compiler.Code.compile(dag_def)
28 | %{updated_mod: updated_mod}
29 | end
30 |
31 | describe "compile/1" do
32 | test "module is compiled and available", %{updated_mod: updated_mod} do
33 | assert Code.ensure_loaded?(updated_mod)
34 | assert "Elixir.Gust.Runner." <> random_udid = to_string(updated_mod)
35 | assert [@original_mod_name, _run_udid] = String.split(random_udid, "_")
36 | end
37 | end
38 |
39 | describe "purge/1" do
40 | test "purge code", %{updated_mod: updated_mod} do
41 | Compiler.Code.purge(updated_mod)
42 |
43 | assert Code.ensure_loaded?(updated_mod) == false
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/stage_coordinator.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.StageCoordinator do
2 | @moduledoc false
3 | @type stage_spec :: map()
4 | @type task_id :: integer()
5 | @type run_id :: integer()
6 | @type task :: Gust.Flows.Task.t()
7 | @type ref :: reference()
8 | @type reason :: term()
9 |
10 | @callback new(list(task_id)) :: stage_spec
11 | @callback put_running(stage_spec, task_id) :: stage_spec
12 | @callback apply_task_result(stage_spec, task, atom()) ::
13 | {:continue, stage_spec}
14 | | {:finished, stage_spec}
15 | | {:reschedule, stage_spec, task, integer()}
16 | @callback update_restart_timer(stage_spec, task, ref) :: stage_spec
17 | @callback process_task(task, map()) ::
18 | :ok | :upstream_failed | :already_processed
19 |
20 | def put_running(stage_spec, task_id), do: impl().put_running(stage_spec, task_id)
21 |
22 | def new(pending_task_ids),
23 | do: impl().new(pending_task_ids)
24 |
25 | def process_task(task, tasks),
26 | do: impl().process_task(task, tasks)
27 |
28 | def apply_task_result(stage_spec, ref, reason),
29 | do: impl().apply_task_result(stage_spec, ref, reason)
30 |
31 | def update_restart_timer(coord, task, ref),
32 | do: impl().update_restart_timer(coord, task, ref)
33 |
34 | def impl,
35 | do:
36 | Application.get_env(:gust, :dag_stage_coordinator, Gust.DAG.StageCoordinator.RetryingRunner)
37 | end
38 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/logger/database.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Logger.Database do
2 | @moduledoc false
3 | alias Gust.Flows
4 | @behaviour Gust.DAG.Logger
5 | @behaviour :gen_event
6 |
7 | def init(__MODULE__) do
8 | {:ok, %{}}
9 | end
10 |
11 | def handle_event(:flush, state), do: {:ok, state}
12 |
13 | def handle_event({level, _gl, {Logger, msg, _ts, md}}, state) do
14 | if md[:task_id] do
15 | Task.start(fn -> handle_log(level, msg, md) end)
16 | end
17 |
18 | {:ok, state}
19 | end
20 |
21 | def handle_call({:configure, opts}, state) do
22 | {:ok, :ok, Map.merge(state, Map.new(opts))}
23 | end
24 |
25 | def set_task(task_id, attempt) do
26 | Logger.metadata(task_id: task_id, attempt: attempt)
27 | end
28 |
29 | def unset do
30 | Logger.reset_metadata()
31 | end
32 |
33 | defp handle_log(_level, "", md),
34 | do: create_log(:error, "nil or empty was logged!", md)
35 |
36 | defp handle_log(level, msg, md) when is_list(msg),
37 | do: create_log(level, Enum.join(msg, "; "), md)
38 |
39 | defp handle_log(level, msg, md) when is_binary(msg),
40 | do: create_log(level, msg, md)
41 |
42 | defp create_log(level, msg, md) do
43 | {:ok, log} =
44 | Flows.create_log(%{
45 | task_id: md[:task_id],
46 | content: msg,
47 | attempt: md[:attempt],
48 | level: to_string(level)
49 | })
50 |
51 | Gust.PubSub.broadcast_log(md[:task_id], log.id)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/flows/secret.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.Secret do
2 | @moduledoc false
3 | use Ecto.Schema
4 | import Ecto.Changeset
5 |
6 | @primary_key {:id, :binary_id, autogenerate: true}
7 | @derive {Jason.Encoder, only: [:id, :name, :value_type, :inserted_at, :updated_at]}
8 | schema "secrets" do
9 | field :name, :string
10 | field :value, Gust.Encrypted.Binary, redact: true
11 | field :value_type, Ecto.Enum, values: [:string, :json]
12 |
13 | timestamps()
14 | end
15 |
16 | @doc false
17 | def changeset(secret, attrs) do
18 | secret
19 | |> cast(attrs, [:name, :value, :value_type])
20 | |> validate_required([:name, :value, :value_type])
21 | |> validate_format(:name, ~r/^[A-Z0-9_]+$/, message: "must be uppercase with underscores")
22 | |> unique_constraint(:name)
23 | |> validate_json_if_needed()
24 | end
25 |
26 | defp validate_json_if_needed(changeset) do
27 | case get_field(changeset, :value_type) do
28 | :json ->
29 | value = get_field(changeset, :value)
30 | validate_json(value, changeset)
31 |
32 | _ ->
33 | changeset
34 | end
35 | end
36 |
37 | defp validate_json(nil, changeset) do
38 | add_error(changeset, :value, "it cannot be empty")
39 | end
40 |
41 | defp validate_json(value, changeset) when is_bitstring(value) do
42 | case Jason.decode(value) do
43 | {:ok, _decoded} -> changeset
44 | {:error, _} -> add_error(changeset, :value, "must be valid JSON")
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/apps/gust_web/assets/vendor/heroicons.js:
--------------------------------------------------------------------------------
1 | const plugin = require("tailwindcss/plugin")
2 | const fs = require("fs")
3 | const path = require("path")
4 |
5 | module.exports = plugin(function({matchComponents, theme}) {
6 | let iconsDir = path.join(__dirname, "../../../../deps/heroicons/optimized")
7 | let values = {}
8 | let icons = [
9 | ["", "/24/outline"],
10 | ["-solid", "/24/solid"],
11 | ["-mini", "/20/solid"],
12 | ["-micro", "/16/solid"]
13 | ]
14 | icons.forEach(([suffix, dir]) => {
15 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
16 | let name = path.basename(file, ".svg") + suffix
17 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
18 | })
19 | })
20 | matchComponents({
21 | "hero": ({name, fullPath}) => {
22 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
23 | content = encodeURIComponent(content)
24 | let size = theme("spacing.6")
25 | if (name.endsWith("-mini")) {
26 | size = theme("spacing.5")
27 | } else if (name.endsWith("-micro")) {
28 | size = theme("spacing.4")
29 | }
30 | return {
31 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
32 | "-webkit-mask": `var(--hero-${name})`,
33 | "mask": `var(--hero-${name})`,
34 | "mask-repeat": "no-repeat",
35 | "background-color": "currentColor",
36 | "vertical-align": "middle",
37 | "display": "inline-block",
38 | "width": size,
39 | "height": size
40 | }
41 | }
42 | }, {values})
43 | })
44 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/file_monitor/worker.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.FileMonitor.Worker do
2 | @moduledoc false
3 |
4 | use GenServer
5 | alias Gust.DAG.Parser
6 | alias Gust.FileMonitor
7 |
8 | @impl true
9 | def init(%{dags_folder: folder, loader: loader}) do
10 | {:ok, watcher_pid} = FileMonitor.start_link(dirs: [folder], latency: 0)
11 | FileMonitor.watch(watcher_pid)
12 | events_queue = MapSet.new()
13 |
14 | {:ok, %{watcher_pid: watcher_pid, events_queue: events_queue, loader: loader}}
15 | end
16 |
17 | def start_link(args) do
18 | GenServer.start_link(__MODULE__, args)
19 | end
20 |
21 | @impl true
22 | def handle_info({:file_event, _watcher_pid, {path, _events}}, %{events_queue: queue} = state) do
23 | if MapSet.member?(queue, path) do
24 | {:noreply, state}
25 | else
26 | Process.send_after(self(), {:check_queue, path}, delay())
27 | {:noreply, %{state | events_queue: MapSet.put(queue, path)}}
28 | end
29 | end
30 |
31 | def handle_info({:check_queue, path}, %{events_queue: queue, loader: loader} = state) do
32 | Parser.maybe_ex_file(path) |> broadcast_path(loader)
33 |
34 | {:noreply, %{state | events_queue: MapSet.delete(queue, path)}}
35 | end
36 |
37 | defp delay, do: Application.get_env(:gust, :file_reload_delay)
38 |
39 | defp broadcast_path(nil, _loader), do: nil
40 |
41 | defp broadcast_path(path, loader) do
42 | action = if File.exists?(path), do: "reload", else: "removed"
43 | dag_name = path |> Path.basename() |> Path.rootname()
44 |
45 | send(loader, {dag_name, Parser.parse(path), action})
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/apps/gust/test/pub_sub_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.PubSubTest do
2 | use ExUnit.Case, async: true
3 |
4 | describe "subscribe_run/1 & broadcast_run_status/2" do
5 | test "delivers the expected message to the right topic" do
6 | run_id = "r-123"
7 | status = :running
8 |
9 | assert :ok == Gust.PubSub.subscribe_run(run_id)
10 | assert :ok == Gust.PubSub.broadcast_run_status(run_id, status)
11 |
12 | assert_receive {:dag, :run_status, %{run_id: ^run_id, status: ^status}}
13 | end
14 |
15 | test "does not leak messages to other run topics" do
16 | subscribed = "r-aaa"
17 | other = "r-bbb"
18 |
19 | assert :ok == Gust.PubSub.subscribe_run(subscribed)
20 | assert :ok == Gust.PubSub.broadcast_run_status(other, :finished)
21 |
22 | refute_receive _any, 50
23 | end
24 | end
25 |
26 | describe "subscribe_runs_for_dag/1 & broadcast_run_started/2" do
27 | test "delivers the expected message to the dag:scheduled topic" do
28 | dag_id = "d-42"
29 | run_id = "r-999"
30 |
31 | assert :ok == Gust.PubSub.subscribe_runs_for_dag(dag_id)
32 | assert :ok == Gust.PubSub.broadcast_run_started(dag_id, run_id)
33 |
34 | assert_receive {:dag, :run_started, %{run_id: ^run_id}}
35 | end
36 |
37 | test "does not leak messages to other dag:scheduled topics" do
38 | subscribed_dag = "d-a"
39 | other_dag = "d-b"
40 |
41 | assert :ok == Gust.PubSub.subscribe_runs_for_dag(subscribed_dag)
42 | assert :ok == Gust.PubSub.broadcast_run_started(other_dag, "r-x")
43 |
44 | refute_receive _any, 50
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :gust, Gust.Repo,
9 | hostname: System.get_env("PGHOST"),
10 | username: System.get_env("PGUSER"),
11 | password: System.get_env("PGPASSWORD"),
12 | database: "gust_rc_test#{System.get_env("MIX_TEST_PARTITION")}",
13 | pool: Ecto.Adapters.SQL.Sandbox,
14 | pool_size: System.schedulers_online() * 2
15 |
16 | # We don't run a server during test. If one is required,
17 | # you can enable the server option below.
18 | config :gust_web, GustWeb.Endpoint,
19 | http: [ip: {127, 0, 0, 1}, port: 4002],
20 | secret_key_base: System.get_env("SECRET_KEY_BASE"),
21 | server: false
22 |
23 | # Print only warnings and errors during test
24 | config :logger, level: :warning
25 |
26 | # In test we don't send emails
27 | config :gust, Gust.Mailer, adapter: Swoosh.Adapters.Test
28 |
29 | # Disable swoosh api client as it is only required for production adapters
30 | config :swoosh, :api_client, false
31 |
32 | # Initialize plugs at runtime for faster test compilation
33 | config :phoenix, :plug_init_mode, :runtime
34 |
35 | # Enable helpful, but potentially expensive runtime checks
36 | config :phoenix_live_view,
37 | enable_expensive_runtime_checks: true
38 |
39 | config :gust, dag_runner_supervisor: Gust.DAGRunnerSupervisorMock
40 | config :gust, dag_task_runner_supervisor: Gust.DAGTaskRunnerSupervisorMock
41 | config :gust, file_reload_delay: 0
42 | config :gust, b64_secrets_cloak_key: System.get_env("B64_SECRETS_CLOAK_KEY")
43 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/terminator/process_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.Terminator.ProcessTest do
2 | import Gust.FlowsFixtures
3 | use Gust.DataCase
4 | alias Gust.DAG.Terminator.Process, as: Terminator
5 |
6 | test "kill_task/2" do
7 | dag = dag_fixture(%{name: "test_dag"})
8 | run = run_fixture(%{dag_id: dag.id})
9 | task = task_fixture(%{run_id: run.id, name: "test_task"})
10 | task_id = task.id
11 |
12 | {:ok, _} = Registry.register(Gust.Registry, "stage_run_#{task.run_id}", nil)
13 |
14 | parent = self()
15 |
16 | spawn(fn ->
17 | {:ok, _} = Registry.register(Gust.Registry, "task_#{task.id}", nil)
18 | send(parent, :registered)
19 | Process.sleep(3_000)
20 | end)
21 |
22 | receive do
23 | :registered -> :ok
24 | after
25 | 100 -> flunk("Registry did not register in time")
26 | end
27 |
28 | [{task_pid, _val}] = Registry.lookup(Gust.Registry, "task_#{task.id}")
29 | ref = Process.monitor(task_pid)
30 | status = :cancelled
31 |
32 | Terminator.kill_task(task, status)
33 |
34 | assert_receive {:DOWN, ^ref, :process, ^task_pid, :killed}, 200
35 |
36 | assert_receive {:task_result, nil, ^task_id, ^status}, 200
37 | end
38 |
39 | test "cancel_timer/2" do
40 | dag = dag_fixture(%{name: "test_dag"})
41 | run = run_fixture(%{dag_id: dag.id})
42 | task = task_fixture(%{run_id: run.id, name: "test_task"})
43 | task_id = task.id
44 |
45 | {:ok, _} = Registry.register(Gust.Registry, "stage_run_#{task.run_id}", nil)
46 | status = :cancelled
47 |
48 | Terminator.cancel_timer(task, status)
49 |
50 | assert_receive {:cancel_timer, ^task_id, ^status}, 200
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Hex Publish
2 | on:
3 | push:
4 | tags:
5 | - "v*.*.*"
6 |
7 | jobs:
8 | publish:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v3
13 |
14 | - uses: erlef/setup-beam@v1
15 | with:
16 | elixir-version: '1.18'
17 | otp-version: '27.0'
18 |
19 | - name: Set up Node.js
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: "18"
23 |
24 | - name: Install NPM packages
25 | working-directory: apps/gust_web/assets
26 | run: npm install
27 |
28 | - name: Gust-web assets deploy
29 | run: |
30 | mix deps.get
31 | mix assets.deploy
32 |
33 | - name: Archive built assets
34 | run: |
35 | mkdir -p release_assets
36 | cp -r apps/gust_web/priv/static release_assets/
37 | cd release_assets
38 | tar -czf gust_static_assets.tar.gz static
39 |
40 | - name: Upload Release Asset
41 | uses: softprops/action-gh-release@v1
42 | with:
43 | files: release_assets/gust_static_assets.tar.gz
44 | token: ${{ secrets.PUBLISH_RELEASE_GH }}
45 |
46 | - name: Publish gust
47 | working-directory: apps/gust
48 | env:
49 | HEX_API_KEY: ${{ secrets.HEX }}
50 | PUBLISH_DEP: true
51 | run: |
52 | mix deps.get
53 | mix hex.build
54 | mix hex.publish --yes --replace
55 |
56 | - name: Publish gust-web
57 | working-directory: apps/gust_web
58 | env:
59 | HEX_API_KEY: ${{ secrets.HEX }}
60 | PUBLISH_DEP: true
61 | run: |
62 | mix deps.get
63 | mix assets.deploy
64 | mix hex.build
65 | mix hex.publish --yes --replace
66 |
67 |
--------------------------------------------------------------------------------
/apps/gust/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | Application.ensure_all_started(:mox)
2 | Mox.defmock(Gust.DAGLoaderMock, for: Gust.DAG.Loader)
3 | Mox.defmock(Gust.FileMonitorMock, for: Gust.FileMonitor)
4 | Mox.defmock(Gust.DAGParserMock, for: Gust.DAG.Parser)
5 | Mox.defmock(Gust.DAGSchedulerMock, for: Gust.DAG.Scheduler)
6 | Mox.defmock(Gust.DAGRunnerSupervisorMock, for: Gust.DAG.RunnerSupervisor)
7 | Mox.defmock(Gust.DAGTaskRunnerSupervisorMock, for: Gust.DAG.TaskRunnerSupervisor)
8 | Mox.defmock(Gust.DAGStageRunnerSupervisorMock, for: Gust.DAG.StageRunnerSupervisor)
9 | Mox.defmock(Gust.DAGStageCoordinatorMock, for: Gust.DAG.StageCoordinator)
10 | Mox.defmock(Gust.DAGCompilerMock, for: Gust.DAG.Compiler)
11 | Mox.defmock(Gust.DAGLoggerMock, for: Gust.DAG.Logger)
12 | Mox.defmock(Gust.DAGRunRestarterMock, for: Gust.DAG.RunRestarter)
13 | Mox.defmock(Gust.DAGTaskDelayerMock, for: Gust.DAG.TaskDelayer)
14 |
15 | Application.put_env(:gust, :dag_parser, Gust.DAGParserMock)
16 | Application.put_env(:gust, :dag_compiler, Gust.DAGCompilerMock)
17 | Application.put_env(:gust, :dag_loader, Gust.DAGLoaderMock)
18 | Application.put_env(:gust, :dag_scheduler, Gust.DAGSchedulerMock)
19 | Application.put_env(:gust, :file_monitor, Gust.FileMonitorMock)
20 | Application.put_env(:gust, :dag_runner_supervisor, Gust.DAGRunnerSupervisorMock)
21 | Application.put_env(:gust, :dag_stage_runner_supervisor, Gust.DAGStageRunnerSupervisorMock)
22 | Application.put_env(:gust, :dag_task_runner_supervisor, Gust.DAGTaskRunnerSupervisorMock)
23 | Application.put_env(:gust, :dag_stage_coordinator, Gust.DAGStageCoordinatorMock)
24 | Application.put_env(:gust, :dag_logger, Gust.DAGLoggerMock)
25 | Application.put_env(:gust, :dag_run_restarter, Gust.DAGRunRestarterMock)
26 | Application.put_env(:gust, :dag_task_delayer, Gust.DAGTaskDelayerMock)
27 |
28 | ExUnit.start()
29 | Ecto.Adapters.SQL.Sandbox.mode(Gust.Repo, :manual)
30 |
--------------------------------------------------------------------------------
/apps/gust/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Gust.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 | alias Ecto.Adapters.SQL.Sandbox
19 |
20 | using do
21 | quote do
22 | alias Gust.Repo
23 |
24 | import Ecto
25 | import Ecto.Changeset
26 | import Ecto.Query
27 | import Gust.DataCase
28 | end
29 | end
30 |
31 | setup tags do
32 | Gust.DataCase.setup_sandbox(tags)
33 | :ok
34 | end
35 |
36 | @doc """
37 | Sets up the sandbox based on the test tags.
38 | """
39 | def setup_sandbox(tags) do
40 | pid = Sandbox.start_owner!(Gust.Repo, shared: not tags[:async])
41 | on_exit(fn -> Sandbox.stop_owner(pid) end)
42 | end
43 |
44 | @doc """
45 | A helper that transforms changeset errors into a map of messages.
46 |
47 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
48 | assert "password is too short" in errors_on(changeset).password
49 | assert %{password: ["password is too short"]} = errors_on(changeset)
50 |
51 | """
52 | def errors_on(changeset) do
53 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
54 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
55 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
56 | end)
57 | end)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Umbrella.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | apps_path: "apps",
7 | version: "0.1.0",
8 | start_permanent: Mix.env() == :prod,
9 | deps: deps(),
10 | aliases: aliases(),
11 | listeners: [Phoenix.CodeReloader],
12 | test_coverage: [tool: ExCoveralls]
13 | ]
14 | end
15 |
16 | def cli do
17 | [
18 | preferred_envs: [precommit: :test]
19 | ]
20 | end
21 |
22 | # Dependencies can be Hex packages:
23 | #
24 | # {:mydep, "~> 0.3.0"}
25 | #
26 | # Or git/path repositories:
27 | #
28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"}
29 | #
30 | # Type "mix help deps" for more examples and options.
31 | #
32 | # Dependencies listed here are available only for this project
33 | # and cannot be accessed from applications inside the apps/ folder.
34 | defp deps do
35 | [
36 | # Required to run "mix format" on ~H/.heex files from the umbrella root
37 | {:phoenix_live_view, ">= 0.0.0"},
38 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
39 | {:excoveralls, "~> 0.18", only: :test},
40 | {:mox, "~> 1.0", only: :test}
41 | ]
42 | end
43 |
44 | # Aliases are shortcuts or tasks specific to the current project.
45 | # For example, to install project dependencies and perform other setup tasks, run:
46 | #
47 | # $ mix setup
48 | #
49 | # See the documentation for `Mix` for more info on aliases.
50 | #
51 | # Aliases listed here are available only for this project
52 | # and cannot be accessed from applications inside the apps/ folder.
53 | defp aliases do
54 | [
55 | # run `mix setup` in all child apps
56 | setup: ["cmd mix setup"],
57 | precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"],
58 | lint: ["credo --strict"]
59 | ]
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/flows/task.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.Task do
2 | @moduledoc false
3 |
4 | use Ecto.Schema
5 | import Ecto.Changeset
6 |
7 | schema "tasks" do
8 | field :name, :string
9 |
10 | field :status, Ecto.Enum,
11 | values: [
12 | :created,
13 | :running,
14 | :succeeded,
15 | :failed,
16 | :retrying,
17 | :upstream_failed,
18 | :enqueued
19 | ],
20 | default: :created
21 |
22 | field :result, :map, default: %{}
23 | field :error, :map, default: %{}
24 | field :attempt, :integer, default: 1
25 | belongs_to :run, Gust.Flows.Run
26 | has_many :logs, Gust.Flows.Log
27 |
28 | timestamps(type: :utc_datetime)
29 | end
30 |
31 | @type t :: %__MODULE__{
32 | id: integer() | nil,
33 | name: String.t() | nil,
34 | status:
35 | :created
36 | | :running
37 | | :succeeded
38 | | :failed
39 | | :retrying
40 | | :upstream_failed
41 | | :enqueued,
42 | result: map(),
43 | error: map(),
44 | attempt: integer(),
45 | run_id: integer() | nil,
46 | run: Gust.Flows.Run.t() | Ecto.Association.NotLoaded.t(),
47 | logs: [Gust.Flows.Log.t()] | Ecto.Association.NotLoaded.t(),
48 | inserted_at: DateTime.t() | nil,
49 | updated_at: DateTime.t() | nil
50 | }
51 |
52 | @doc false
53 | def changeset(task, attrs) do
54 | task
55 | |> cast(attrs, [:name, :status, :run_id, :result, :attempt, :error])
56 | |> validate_required([:name, :status, :run_id, :result, :error])
57 | end
58 |
59 | @doc false
60 | def test_changeset(run, attrs) do
61 | run
62 | |> cast(attrs, [:inserted_at, :updated_at, :name, :status, :run_id, :result, :attempt, :error])
63 | |> validate_required([:name, :status, :run_id, :result, :error])
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :gust_web
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_gust_web_key",
10 | signing_salt: "bfUJ4RCX",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket,
15 | websocket: [connect_info: [session: @session_options]],
16 | longpoll: [connect_info: [session: @session_options]]
17 |
18 | # Serve at "/" the static files from "priv/static" directory.
19 | #
20 | # When code reloading is disabled (e.g., in production),
21 | # the `gzip` option is enabled to serve compressed
22 | # static files generated by running `phx.digest`.
23 | app_name = Application.compile_env(:gust, :app_name, :gust_web)
24 |
25 | plug Plug.Static,
26 | at: "/",
27 | from: app_name,
28 | gzip: not code_reloading?,
29 | only: GustWeb.static_paths()
30 |
31 | # Code reloading can be explicitly enabled under the
32 | # :code_reloader configuration of your endpoint.
33 | if code_reloading? do
34 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
35 | plug Phoenix.LiveReloader
36 | plug Phoenix.CodeReloader
37 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :gust_web
38 | end
39 |
40 | plug Phoenix.LiveDashboard.RequestLogger,
41 | param_key: "request_logger",
42 | cookie_key: "request_logger"
43 |
44 | plug Plug.RequestId
45 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
46 |
47 | plug Plug.Parsers,
48 | parsers: [:urlencoded, :multipart, :json],
49 | pass: ["*/*"],
50 | json_decoder: Phoenix.json_library()
51 |
52 | plug Plug.MethodOverride
53 | plug Plug.Head
54 | plug Plug.Session, @session_options
55 | plug GustWeb.Router
56 | end
57 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/runner/task_worker.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Runner.TaskWorker do
2 | @moduledoc false
3 | use GenServer
4 | alias Gust.DAG
5 |
6 | @impl true
7 | def init(init_arg) do
8 | {:ok, init_arg, {:continue, :init_run}}
9 | end
10 |
11 | def start_link(args) do
12 | GenServer.start_link(__MODULE__, args, name: via_tuple("task_#{args[:task].id}"))
13 | end
14 |
15 | defp via_tuple(name) do
16 | {:via, Registry, {Gust.Registry, name}}
17 | end
18 |
19 | def child_spec(args) do
20 | %{
21 | id: __MODULE__,
22 | start: {__MODULE__, :start_link, [args]},
23 | restart: :temporary,
24 | type: :worker
25 | }
26 | end
27 |
28 | @impl true
29 | def handle_continue(:init_run, state) do
30 | send(self(), :run)
31 |
32 | {:noreply, state}
33 | end
34 |
35 | @impl true
36 | def handle_info(
37 | :run,
38 | %{task: task, mod: mod, stage_pid: stage_pid, opts: opts} = state
39 | ) do
40 | fun_name = String.to_atom(task.name)
41 | args = [%{run_id: task.run_id}]
42 |
43 | DAG.Logger.set_task(task.id, task.attempt)
44 |
45 | {status, result} =
46 | case try_run(mod, fun_name, args, opts[:store_result]) do
47 | {:ok, result} ->
48 | {:ok, result}
49 |
50 | {:error, error} ->
51 | {:error, error}
52 | end
53 |
54 | DAG.Logger.unset()
55 |
56 | send(stage_pid, {:task_result, result, task.id, status})
57 |
58 | {:stop, :normal, state}
59 | end
60 |
61 | defp try_run(mod, fun_name, args, store_result) do
62 | apply_and_validate(mod, fun_name, args, store_result)
63 | rescue
64 | e -> {:error, e}
65 | end
66 |
67 | defp apply_and_validate(mod, fun_name, args, store_result) do
68 | result = apply(mod, fun_name, args)
69 |
70 | if store_result && !is_map(result) do
71 | raise "Task returned #{inspect(result)} but store_result requires a map"
72 | else
73 | {:ok, result}
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.Router do
2 | use GustWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, html: {GustWeb.Layouts, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | auth_enabled? = Application.compile_env(:gust_web, :basic_auth)
14 |
15 | if auth_enabled? do
16 | defp basic_auth(conn, _opts) do
17 | Plug.BasicAuth.basic_auth(conn,
18 | username: System.get_env("BASIC_AUTH_USER"),
19 | password: System.get_env("BASIC_AUTH_PASS")
20 | )
21 | end
22 | end
23 |
24 | scope "/", GustWeb do
25 | pipe_through if auth_enabled?, do: [:browser, :basic_auth], else: :browser
26 |
27 | get "/", PageController, :home
28 | live "/dags", DagLive.Index, :index
29 | live "/dags/:name/dashboard", DagLive.Dashboard, :dashboard
30 | live "/dags/:name/runs", RunLive.Index, :index
31 | live "/secrets", SecretLive.Index, :index
32 | live "/secrets/new", SecretLive.Index, :new
33 | live "/secrets/:id/edit", SecretLive.Index, :edit
34 | end
35 |
36 | # Other scopes may use custom stacks.
37 | # scope "/api", GustWeb do
38 | # pipe_through :api
39 | # end
40 |
41 | # Enable LiveDashboard and Swoosh mailbox preview in development
42 | if Application.compile_env(:gust_web, :dev_routes) do
43 | # If you want to use the LiveDashboard in production, you should put
44 | # it behind authentication and allow only admins to access it.
45 | # If your application does not have an admins-only section yet,
46 | # you can use Plug.BasicAuth to set up some basic authentication
47 | # as long as you are also using SSL (which you should anyway).
48 | import Phoenix.LiveDashboard.Router
49 |
50 | scope "/dev" do
51 | pipe_through :browser
52 |
53 | live_dashboard "/dashboard", metrics: GustWeb.Telemetry
54 | forward "/mailbox", Plug.Swoosh.MailboxPreview
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 | name: Build and test
15 | runs-on: ubuntu-latest
16 | env:
17 | MIX_ENV: test
18 | PGUSER: postgres
19 | PGPASSWORD: postgres
20 | PGHOST: localhost
21 | PGPORT: 5432
22 | B64_SECRETS_CLOAK_KEY: PF4/V7ZLI8Nyjymua0eUy2K8q/lcSFi3USe3UUDb7sY=
23 | SECRET_KEY_BASE: dWKtgAuY24rgqDQCLkN9S5CaOqSFzdrnqO4iHqVlIp5uLQPB6MjVJqlEN7w1nOvX
24 |
25 | services:
26 | postgres:
27 | image: postgres:14
28 | ports: ['5432:5432']
29 | env:
30 | POSTGRES_USER: postgres
31 | POSTGRES_PASSWORD: postgres
32 | options: >-
33 | --health-cmd "pg_isready -U postgres"
34 | --health-interval 10s
35 | --health-timeout 5s
36 | --health-retries 5
37 |
38 | steps:
39 | - uses: actions/checkout@v4
40 |
41 | - name: Set up Elixir
42 | uses: erlef/setup-beam@v1
43 | with:
44 | elixir-version: '1.18'
45 | otp-version: '27.0'
46 |
47 | - name: Restore dependencies cache
48 | uses: actions/cache@v3
49 | with:
50 | path: deps
51 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
52 | restore-keys: ${{ runner.os }}-mix-
53 |
54 | - name: Install deps
55 | run: mix deps.get
56 |
57 | - name: Check formatting
58 | run: mix format --check-formatted
59 |
60 | - name: Check warnings
61 | run: mix compile --warnings-as-errors
62 |
63 | - name: Run lint
64 | run: mix lint
65 |
66 | - name: Create and migrate DB
67 | run: |
68 | mix ecto.create
69 | mix ecto.migrate
70 |
71 | - name: Run tests
72 | run: mix coveralls.json --umbrella
73 |
74 | - name: Coveralls
75 | uses: coverallsapp/github-action@v2
76 | with:
77 | file: cover/excoveralls.json
78 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/mermaid_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.MermaidTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias GustWeb.Mermaid
5 |
6 | defp edges_from_chart(chart) do
7 | chart
8 | |> String.split("\n", parts: 2)
9 | |> List.last()
10 | |> String.trim()
11 | |> String.split(~r/\R+/, trim: true)
12 | |> Enum.reject(&(&1 == ""))
13 | |> MapSet.new()
14 | end
15 |
16 | test "chart/1 with a single task and no upstream deps" do
17 | tasks = [
18 | {"A", %{downstream: MapSet.new(), upstream: MapSet.new()}}
19 | ]
20 |
21 | assert Mermaid.chart(tasks) == "flowchart LR\n \nA"
22 | end
23 |
24 | test "chart/1 with a single upstream dependency" do
25 | tasks = [
26 | {"A", %{downstream: MapSet.new(), upstream: MapSet.new(["X"])}}
27 | ]
28 |
29 | assert Mermaid.chart(tasks) == "flowchart LR\n \nX --> A"
30 | end
31 |
32 | test "chart/1 with multiple tasks and multiple upstreams (order-agnostic)" do
33 | tasks = [
34 | {"B", %{downstream: MapSet.new(), upstream: MapSet.new(["A", "C"])}},
35 | {"D", %{downstream: MapSet.new(), upstream: MapSet.new(["B"])}}
36 | ]
37 |
38 | chart = Mermaid.chart(tasks)
39 |
40 | assert String.starts_with?(chart, "flowchart LR\n")
41 |
42 | expected_edges =
43 | MapSet.new([
44 | "A --> B",
45 | "C --> B",
46 | "B --> D"
47 | ])
48 |
49 | assert edges_from_chart(chart) == expected_edges
50 | end
51 |
52 | test "chart/1 accepts a map of tasks as well (order-agnostic)" do
53 | tasks =
54 | %{
55 | "B" => %{downstream: MapSet.new(), upstream: MapSet.new(["A"])},
56 | "C" => %{downstream: MapSet.new(), upstream: MapSet.new(["A"])},
57 | "D" => %{downstream: MapSet.new(), upstream: MapSet.new(["B", "C"])}
58 | }
59 |
60 | chart = Mermaid.chart(tasks)
61 |
62 | expected_edges =
63 | MapSet.new([
64 | "A --> B",
65 | "A --> C",
66 | "B --> D",
67 | "C --> D"
68 | ])
69 |
70 | assert edges_from_chart(chart) == expected_edges
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/dag/graph.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.DAG.Graph do
2 | @moduledoc false
3 |
4 | def build_branch(tasks, direction, name) do
5 | branch = tasks[name][direction]
6 |
7 | if branch == MapSet.new([]) do
8 | [name]
9 | else
10 | siblings = for sibling_name <- branch, do: build_branch(tasks, direction, sibling_name)
11 | [name | siblings]
12 | end
13 | end
14 |
15 | def to_stages(tasks) do
16 | {:ok, sort(tasks)}
17 | rescue
18 | e in Gust.DAG.Graph.CycleDection ->
19 | {:error, e}
20 | end
21 |
22 | def link_tasks(task_list) do
23 | downstreams = build_downstreams(task_list)
24 | upstreams = build_upstreams(downstreams)
25 | merge_streams(downstreams, upstreams)
26 | end
27 |
28 | defp build_downstreams(task_list) do
29 | Map.new(task_list, fn
30 | {name, opts} when is_list(opts) ->
31 | {to_string(name),
32 | opts |> Keyword.get(:downstream, []) |> Enum.map(&to_string/1) |> MapSet.new()}
33 | end)
34 | end
35 |
36 | defp build_upstreams(downstreams) do
37 | Enum.reduce(downstreams, %{}, fn {node, ds}, acc ->
38 | Enum.reduce(ds, acc, fn d, acc2 ->
39 | Map.update(acc2, to_string(d), MapSet.new([node]), &MapSet.put(&1, node))
40 | end)
41 | end)
42 | end
43 |
44 | defp merge_streams(downstreams, upstreams) do
45 | Map.new(downstreams, fn {node, ds} ->
46 | {node, %{downstream: ds, upstream: Map.get(upstreams, node, MapSet.new())}}
47 | end)
48 | end
49 |
50 | def sort(tasks, sorted \\ [])
51 | def sort(tasks, sorted) when map_size(tasks) == 0, do: sorted
52 |
53 | def sort(tasks, sorted) do
54 | {current_task_layer, next_tasks} =
55 | tasks |> Map.split_with(fn {_k, v} -> MapSet.size(v[:upstream]) == 0 end)
56 |
57 | layer_keys = Map.keys(current_task_layer)
58 |
59 | next_tasks =
60 | next_tasks
61 | |> Map.new(fn {k, v} ->
62 | removed_up = MapSet.difference(v[:upstream], MapSet.new(layer_keys))
63 | {k, %{v | upstream: removed_up}}
64 | end)
65 |
66 | if map_size(current_task_layer) == 0, do: raise(Gust.DAG.Graph.CycleDection)
67 |
68 | sort(next_tasks, sorted ++ [layer_keys])
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/apps/gust/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.MixProject do
2 | use Mix.Project
3 |
4 | @version "0.1.25"
5 |
6 | def project do
7 | [
8 | app: :gust,
9 | version: @version,
10 | build_path: "../../_build",
11 | config_path: "../../config/config.exs",
12 | deps_path: "../../deps",
13 | lockfile: "../../mix.lock",
14 | elixir: "~> 1.15",
15 | elixirc_paths: elixirc_paths(Mix.env()),
16 | start_permanent: Mix.env() == :prod,
17 | aliases: aliases(),
18 | deps: deps(),
19 | test_coverage: [tool: ExCoveralls],
20 | description: "A DAG-Based Workflow Orchestration Engine for Elixir",
21 | package: [
22 | licenses: ["Apache-2.0"],
23 | links: %{"GitHub" => "https://github.com/marciok/gust"}
24 | ]
25 | ]
26 | end
27 |
28 | # Configuration for the OTP application.
29 | #
30 | # Type `mix help compile.app` for more information.
31 | def application do
32 | [
33 | mod: {Gust.Application, []},
34 | extra_applications: [:logger, :runtime_tools]
35 | ]
36 | end
37 |
38 | # Specifies which paths to compile per environment.
39 | defp elixirc_paths(:test), do: ["lib", "test/support"]
40 | defp elixirc_paths(_), do: ["lib"]
41 |
42 | # Specifies your project dependencies.
43 | #
44 | # Type `mix help deps` for examples and options.
45 | defp deps do
46 | [
47 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
48 | {:dns_cluster, "~> 0.2.0"},
49 | {:phoenix_pubsub, "~> 2.1"},
50 | {:ecto_sql, "~> 3.13"},
51 | {:postgrex, ">= 0.0.0"},
52 | {:jason, "~> 1.2"},
53 | {:swoosh, "~> 1.16"},
54 | {:req, "~> 0.5"},
55 | {:quantum, "~> 3.0"},
56 | {:cloak_ecto, "~> 1.2.0"},
57 | {:file_system, "~> 1.1", only: [:dev, :test]}
58 | ]
59 | end
60 |
61 | # Aliases are shortcuts or tasks specific to the current project.
62 | #
63 | # See the documentation for `Mix` for more info on aliases.
64 | defp aliases do
65 | [
66 | setup: ["deps.get", "ecto.setup"],
67 | "ecto.setup": ["ecto.create", "ecto.migrate", "run #{__DIR__}/priv/repo/seeds.exs"],
68 | "ecto.reset": ["ecto.drop", "ecto.setup"],
69 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
70 | ]
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/apps/gust/lib/gust/pub_sub.ex:
--------------------------------------------------------------------------------
1 | defmodule Gust.PubSub do
2 | @moduledoc false
3 |
4 | # Topic bases
5 | @topic_run "dag:run"
6 | @topic_task "dag:task"
7 | @topic_file "dag:file"
8 |
9 | # Event atoms
10 | @event_run_started :run_started
11 | @event_run_status :run_status
12 | @event_file_update :file_updated
13 |
14 | ## Broadcasts
15 | #
16 | def broadcast_log(task_id, log_id) do
17 | Phoenix.PubSub.broadcast(
18 | __MODULE__,
19 | "#{@topic_task}:#{task_id}",
20 | {:task, :log, %{task_id: task_id, log_id: log_id}}
21 | )
22 | end
23 |
24 | def broadcast_run_status(run_id, status) do
25 | Phoenix.PubSub.broadcast(
26 | __MODULE__,
27 | "#{@topic_run}:#{run_id}",
28 | {:dag, @event_run_status, %{run_id: run_id, status: status}}
29 | )
30 | end
31 |
32 | def broadcast_run_started(dag_id, run_id) do
33 | Phoenix.PubSub.broadcast(
34 | __MODULE__,
35 | "#{@topic_run}:#{dag_id}",
36 | {:dag, @event_run_started, %{dag_id: dag_id, run_id: run_id}}
37 | )
38 | end
39 |
40 | def broadcast_file_update(name, parse_result, action) do
41 | payload =
42 | {:dag, @event_file_update, %{dag_name: name, parse_result: parse_result, action: action}}
43 |
44 | # Broadcast to all-file topic
45 | Phoenix.PubSub.broadcast(__MODULE__, "#{@topic_file}:update", payload)
46 |
47 | # Broadcast to specific-file topic
48 | Phoenix.PubSub.broadcast(__MODULE__, "#{@topic_file}:#{name}", payload)
49 | end
50 |
51 | ## Subscriptions
52 |
53 | # Subscribe to updates for *all* files
54 | def subscribe_all_files(action) do
55 | Phoenix.PubSub.subscribe(__MODULE__, "#{@topic_file}:#{action}")
56 | end
57 |
58 | # Subscribe to updates for a *specific* file
59 | def subscribe_file(name) do
60 | Phoenix.PubSub.subscribe(__MODULE__, "#{@topic_file}:#{name}")
61 | end
62 |
63 | # Subscribe to a single run’s status
64 | def subscribe_run(run_id) do
65 | Phoenix.PubSub.subscribe(__MODULE__, "#{@topic_run}:#{run_id}")
66 | end
67 |
68 | def subscribe_task(task_id) do
69 | Phoenix.PubSub.subscribe(__MODULE__, "#{@topic_task}:#{task_id}")
70 | end
71 |
72 | # Subscribe to all runs under a given DAG
73 | def subscribe_runs_for_dag(dag_id) do
74 | Phoenix.PubSub.subscribe(__MODULE__, "#{@topic_run}:#{dag_id}")
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/run_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.RunLive.Index do
2 | alias Gust.Flows
3 | alias Gust.PubSub
4 | use GustWeb, :live_view
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, assign(socket, :page_title, "Listing Runs")}
9 | end
10 |
11 | @impl true
12 | def handle_params(%{"name" => name, "page_size" => page_size, "page" => page}, _uri, socket) do
13 | page_size = String.to_integer(page_size)
14 | page = String.to_integer(page)
15 |
16 | dag = load_dag(page, page_size, name)
17 | runs_count = Flows.count_runs_on_dag(dag.id)
18 | pages = div(runs_count + page_size - 1, page_size)
19 |
20 | if connected?(socket) do
21 | PubSub.subscribe_runs_for_dag(dag.id)
22 | Enum.each(dag.runs, fn %{id: id} -> PubSub.subscribe_run(id) end)
23 | end
24 |
25 | {:noreply,
26 | socket
27 | |> assign(:dag, dag)
28 | |> assign(:page_size, page_size)
29 | |> assign(:runs_count, runs_count)
30 | |> assign(:page, page)
31 | |> assign(:pages, 1..pages)
32 | |> stream(:runs, dag.runs, reset: true)}
33 | end
34 |
35 | @impl true
36 | def handle_event("select_page", %{"page" => num}, socket) do
37 | dag = socket.assigns.dag
38 | page_size = socket.assigns.page_size
39 |
40 | {:noreply,
41 | socket |> push_patch(to: ~p"/dags/#{dag.name}/runs?page_size=#{page_size}&page=#{num}")}
42 | end
43 |
44 | @impl true
45 | def handle_event("delete", %{"id" => id}, socket) do
46 | run = Flows.get_run!(id)
47 | {:ok, _} = Flows.delete_run(run)
48 |
49 | {:noreply, socket |> stream_delete(:runs, run)}
50 | end
51 |
52 | @impl true
53 | def handle_info(
54 | {:dag, :run_started, %{run_id: run_id}},
55 | socket
56 | ) do
57 | run = Flows.get_run!(run_id)
58 | PubSub.subscribe_run(run_id)
59 |
60 | {:noreply, stream_insert(socket, :runs, run, at: 0)}
61 | end
62 |
63 | @impl true
64 | def handle_info(
65 | {:dag, :run_status, %{run_id: run_id, status: _status}},
66 | socket
67 | ) do
68 | run = Flows.get_run!(run_id)
69 |
70 | {:noreply, stream_insert(socket, :runs, run)}
71 | end
72 |
73 | defp load_dag(page, size, name) do
74 | offset = (page - 1) * size
75 |
76 | Flows.get_dag_by_name_with_runs!(name, limit: size, offset: offset)
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/graph_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Dag.GraphTest do
2 | alias Gust.DAG.Graph
3 | use Gust.DataCase
4 |
5 | @graph %{
6 | "bye" => %{
7 | downstream: MapSet.new([]),
8 | upstream: MapSet.new(["for_me", "wait"])
9 | },
10 | "hi" => %{
11 | downstream: MapSet.new(["for_me", "wait"]),
12 | upstream: MapSet.new([])
13 | },
14 | "for_me" => %{
15 | downstream: MapSet.new(["bye"]),
16 | upstream: MapSet.new(["hi"])
17 | },
18 | "wait" => %{
19 | downstream: MapSet.new(["bye"]),
20 | upstream: MapSet.new(["hi"])
21 | }
22 | }
23 |
24 | describe "build_branch/3" do
25 | test "when direction is upstream" do
26 | direction = :upstream
27 |
28 | assert [
29 | "bye",
30 | ["for_me", ["hi"]],
31 | ["wait", ["hi"]]
32 | ] =
33 | Graph.build_branch(@graph, direction, "bye")
34 | end
35 |
36 | test "when direction is downstream" do
37 | direction = :downstream
38 |
39 | assert [
40 | "hi",
41 | ["for_me", ["bye"]],
42 | ["wait", ["bye"]]
43 | ] =
44 | Graph.build_branch(@graph, direction, "hi")
45 | end
46 | end
47 |
48 | describe "to_stage/1" do
49 | test "no cycle detected" do
50 | stages = [
51 | ["hi"],
52 | ["for_me", "wait"],
53 | ["bye"]
54 | ]
55 |
56 | assert Graph.to_stages(@graph) == {:ok, stages}
57 | end
58 |
59 | test "cycle is detected" do
60 | graph =
61 | %{
62 | "bye" => %{
63 | downstream: MapSet.new(["hi"]),
64 | upstream: MapSet.new(["hi"])
65 | },
66 | "hi" => %{
67 | downstream: MapSet.new(["bye"]),
68 | upstream: MapSet.new(["bye"])
69 | }
70 | }
71 |
72 | assert {:error, %Graph.CycleDection{message: "Possible cycle detected"}} ==
73 | Graph.to_stages(graph)
74 | end
75 | end
76 |
77 | describe "link_tasks/1" do
78 | test "linked tasks map" do
79 | tasks_list = [
80 | {:bye, []},
81 | {:wait, [downstream: [:bye]]},
82 | {:for_me, [downstream: [:bye]]},
83 | {:hi, [downstream: [:wait, :for_me]]}
84 | ]
85 |
86 | assert @graph == Graph.link_tasks(tasks_list)
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/dag_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.DagLive.Index do
2 | alias Gust.DAG.{Loader, RunRestarter}
3 | alias Gust.Flows
4 | alias Gust.PubSub
5 | use GustWeb, :live_view
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | PubSub.subscribe_all_files("update")
10 |
11 | dag_defs = Loader.get_definitions()
12 |
13 | dags =
14 | for {dag_id, {:ok, dag_def}} <- dag_defs do
15 | dag = Flows.get_dag!(dag_id)
16 | %{id: dag.name, dag: dag, dag_def: dag_def}
17 | end
18 |
19 | broken_dags =
20 | for {dag_id, {:error, error}} <- dag_defs do
21 | dag = Flows.get_dag!(dag_id)
22 | %{id: dag.name, dag: dag, error: error}
23 | end
24 |
25 | {:ok,
26 | socket
27 | |> assign(:page_title, "DAGs Listing")
28 | |> stream(:dags, dags)
29 | |> stream(:broken_dags, broken_dags)}
30 | end
31 |
32 | @impl true
33 | def handle_event("trigger_run", %{"id" => id}, socket) do
34 | run = RunRestarter.start_dag(String.to_integer(id))
35 | run = Flows.get_run_with_tasks!(run.id)
36 |
37 | {:noreply, socket |> put_flash(:info, "Run #{run.id} triggered")}
38 | end
39 |
40 | @impl true
41 | def handle_info(
42 | {:dag, :file_updated,
43 | %{action: "removed", dag_name: name, parse_result: {:error, _error}}},
44 | socket
45 | ) do
46 | {:noreply, socket |> stream_delete(:dags, %{id: name})}
47 | end
48 |
49 | @impl true
50 | def handle_info(
51 | {:dag, :file_updated, %{action: "reload", dag_name: name, parse_result: {:error, error}}},
52 | socket
53 | ) do
54 | dag = Flows.get_dag_by_name(name)
55 | socket = stream_insert(socket, :broken_dags, %{id: name, dag: dag, error: error})
56 | socket = stream_delete(socket, :dags, %{id: name})
57 | {:noreply, socket}
58 | end
59 |
60 | @impl true
61 | def handle_info(
62 | {:dag, :file_updated, %{action: "reload", parse_result: {:ok, dag_def}}},
63 | socket
64 | ) do
65 | name = dag_def.name
66 | dag = Flows.get_dag_by_name(name)
67 | socket = insert_dag(socket, dag, dag_def)
68 | socket = stream_delete(socket, :broken_dags, %{id: dag.name})
69 | {:noreply, socket}
70 | end
71 |
72 | defp insert_dag(socket, dag, dag_def) do
73 | stream_insert(socket, :dags, %{id: dag.name, dag: dag, dag_def: dag_def})
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/live/breadcrumbs_live_component_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.BreadcrumbsLiveComponentTest do
2 | use GustWeb.ConnCase
3 | import Phoenix.LiveViewTest
4 | import Gust.FlowsFixtures
5 |
6 | require GustWeb.LiveComponentTest
7 | import GustWeb.LiveComponentTest
8 |
9 | setup do
10 | dag = dag_fixture(%{name: "my_dag"})
11 | Gust.Flows.get_dag!(dag.id)
12 |
13 | dag_def = %Gust.DAG.Definition{
14 | name: "my_dag",
15 | options: [schedule: "* * * *"]
16 | }
17 |
18 | %{dag: dag, dag_def: dag_def}
19 | end
20 |
21 | test "only dag is provided", %{conn: conn, dag_def: dag_def} do
22 | {:ok, breadcrumbs, _html} =
23 | live_component_isolated(conn, GustWeb.BreadcrumbsComponent, %{
24 | run: nil,
25 | task: nil,
26 | dag_def: dag_def
27 | })
28 |
29 | assert breadcrumbs |> element("#dag-runs-link") |> render_click()
30 | assert_redirect breadcrumbs, "/dags/#{dag_def.name}/dashboard"
31 |
32 | {:ok, breadcrumbs, _html} =
33 | live_component_isolated(conn, GustWeb.BreadcrumbsComponent, %{
34 | run: nil,
35 | task: nil,
36 | dag_def: dag_def
37 | })
38 |
39 | assert breadcrumbs |> element("#dags-link") |> render_click()
40 | assert_redirect breadcrumbs, "/dags"
41 | end
42 |
43 | test "dag and run is provided", %{conn: conn, dag: dag, dag_def: dag_def} do
44 | run = run_fixture(%{dag_id: dag.id})
45 |
46 | {:ok, breadcrumbs, _html} =
47 | live_component_isolated(conn, GustWeb.BreadcrumbsComponent, %{
48 | run: run,
49 | task: nil,
50 | dag_def: dag_def
51 | })
52 |
53 | assert breadcrumbs |> element("#dag-run-link") |> render_click()
54 | assert_redirect breadcrumbs, "/dags/#{dag_def.name}/dashboard?run_id=#{run.id}"
55 | end
56 |
57 | test "dag, run and task is provided", %{conn: conn, dag: dag, dag_def: dag_def} do
58 | run = run_fixture(%{dag_id: dag.id})
59 | task = task_fixture(%{run_id: run.id, name: "hello_breadcrumb"})
60 |
61 | {:ok, breadcrumbs, _html} =
62 | live_component_isolated(conn, GustWeb.BreadcrumbsComponent, %{
63 | run: run,
64 | task: task,
65 | dag_def: dag_def
66 | })
67 |
68 | assert breadcrumbs |> element("#dag-run-task-link") |> render_click()
69 |
70 | assert_redirect breadcrumbs,
71 | "/dags/#{dag_def.name}/dashboard?run_id=#{run.id}&task_name=#{task.name}"
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/run_live/index.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 | <.link
6 | class="inline-flex items-center gap-1 px-3 py-2 hover:text-slate-900"
7 | navigate={~p"/dags/#{@dag.name}/dashboard"}
8 | >
9 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> Back
10 |
11 |
12 |
13 |
14 |
15 |
16 |
35 |
36 |
37 |
38 | <.table id="runs" rows={@streams.runs}>
39 | <:col :let={{_id, run}} label="Status">
40 | <.status_badge status={run.status} data-testid="status-badge" />
41 |
42 |
43 | --%>
44 | <:col :let={{_id, run}} label="Inserted At">
45 | {to_string(run.inserted_at)}
46 |
47 |
48 | <:col :let={{_id, run}} label="Updated At">
49 | {to_string(run.updated_at)}
50 |
51 |
52 | <:col :let={{_id, run}} label="ID">
53 | {run.id}
54 |
55 |
56 | <:action :let={{id, run}}>
57 | <.link
58 | phx-click={JS.push("delete", value: %{id: run.id}) |> hide("##{id}")}
59 | data-confirm="Are you sure? Make sure it is completed, otherwise it may cause unexpected behavior."
60 | class="btn btn-sm btn-soft btn-error"
61 | >
62 | Delete
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/secret_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.SecretLive.Index do
2 | use GustWeb, :live_view
3 |
4 | alias Gust.Flows
5 | alias Gust.Flows.Secret
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | {:ok, stream(socket, :secrets, Flows.list_secrets())}
10 | end
11 |
12 | @impl true
13 | def handle_params(params, _url, socket) do
14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
15 | end
16 |
17 | defp apply_action(socket, :index, _params) do
18 | socket
19 | |> assign(:page_title, "Listing Secrets")
20 | |> assign(:secret, nil)
21 | end
22 |
23 | defp apply_action(socket, :edit, %{"id" => id}) do
24 | secret = Flows.get_secret!(id)
25 | secret = secret |> Map.put(:value, "")
26 |
27 | socket
28 | |> assign(:page_title, "Edit Secret")
29 | |> assign(:secret, secret)
30 | |> assign(:form, to_form(Flows.change_secret(secret)))
31 | end
32 |
33 | defp apply_action(socket, :new, _params) do
34 | secret = %Secret{}
35 |
36 | socket
37 | |> assign(:page_title, "New Secret")
38 | |> assign(:secret, %Secret{})
39 | |> assign(:form, to_form(Flows.change_secret(secret)))
40 | end
41 |
42 | @impl true
43 | def handle_event("validate", %{"secret" => secret_params}, socket) do
44 | changeset = Flows.change_secret(socket.assigns.secret, secret_params)
45 | {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
46 | end
47 |
48 | def handle_event("save", %{"secret" => secret_params}, socket) do
49 | action = socket.assigns.live_action
50 | save_secret(socket, action, secret_params)
51 | end
52 |
53 | @impl true
54 | def handle_event("delete", %{"id" => id}, socket) do
55 | secret = Flows.get_secret!(id)
56 | {:ok, _} = Flows.delete_secret(secret)
57 |
58 | {:noreply, socket |> stream_delete(:secrets, secret)}
59 | end
60 |
61 | defp save_secret(socket, :new, secret_params) do
62 | case Flows.create_secret(secret_params) do
63 | {:ok, secret} ->
64 | socket |> apply_secret(secret, "created")
65 | end
66 | end
67 |
68 | defp save_secret(socket, :edit, secret_params) do
69 | case Flows.update_secret(socket.assigns.secret, secret_params) do
70 | {:ok, secret} ->
71 | socket |> apply_secret(secret, "updated")
72 | end
73 | end
74 |
75 | defp apply_secret(socket, secret, action_verb) do
76 | {:noreply,
77 | socket
78 | |> stream_insert(:secrets, secret)
79 | |> put_flash(:info, "Secret #{action_verb} successfully")
80 | |> push_patch(to: ~p"/secrets")}
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your umbrella
2 | # and **all applications** and their dependencies with the
3 | # help of the Config module.
4 | #
5 | # Note that all applications in your umbrella share the
6 | # same configuration and dependencies, which is why they
7 | # all use the same configuration file. If you want different
8 | # configurations or dependencies per app, it is best to
9 | # move said applications out of the umbrella.
10 | import Config
11 |
12 | # Configure Mix tasks and generators
13 | config :gust,
14 | ecto_repos: [Gust.Repo]
15 |
16 | # Configures the mailer
17 | #
18 | # By default it uses the "Local" adapter which stores the emails
19 | # locally. You can see the emails in your browser, at "/dev/mailbox".
20 | #
21 | # For production it's recommended to configure a different adapter
22 | # at the `config/runtime.exs`.
23 | config :gust, Gust.Mailer, adapter: Swoosh.Adapters.Local
24 |
25 | config :gust_web,
26 | ecto_repos: [Gust.Repo],
27 | generators: [context_app: :gust]
28 |
29 | # Configures the endpoint
30 | config :gust_web, GustWeb.Endpoint,
31 | url: [host: "localhost"],
32 | adapter: Bandit.PhoenixAdapter,
33 | render_errors: [
34 | formats: [html: GustWeb.ErrorHTML, json: GustWeb.ErrorJSON],
35 | layout: false
36 | ],
37 | pubsub_server: Gust.PubSub,
38 | live_view: [signing_salt: "SCG6tRFf"]
39 |
40 | # Configure esbuild (the version is required)
41 | config :esbuild,
42 | version: "0.25.4",
43 | gust_web: [
44 | args:
45 | ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
46 | cd: Path.expand("../apps/gust_web/assets", __DIR__),
47 | env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
48 | ]
49 |
50 | # Configure tailwind (the version is required)
51 | config :tailwind,
52 | version: "4.1.7",
53 | gust_web: [
54 | args: ~w(
55 | --input=assets/css/app.css
56 | --output=priv/static/assets/css/app.css
57 | ),
58 | cd: Path.expand("../apps/gust_web", __DIR__)
59 | ]
60 |
61 | config :gust, dag_logger: Gust.DAG.Logger.Database
62 | # Configures Elixir's Logger
63 | config :logger, :default_formatter,
64 | format: "$time $metadata[$level] $message\n",
65 | metadata: [:request_id, :task_id, :attempt]
66 |
67 | config :logger, backends: [:console, Gust.DAG.Logger.Database]
68 |
69 | # Use Jason for JSON parsing in Phoenix
70 | config :phoenix, :json_library, Jason
71 |
72 | # Import environment specific config. This must remain at the bottom
73 | # of this file so it overrides the configuration defined above.
74 | import_config "#{config_env()}.exs"
75 |
--------------------------------------------------------------------------------
/apps/gust/test/dag/scheduler/worker_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DAG.Scheduler.WorkerTest do
2 | import Crontab.CronExpression
3 | use Gust.DataCase
4 | import Gust.FlowsFixtures
5 | import Gust.FSHelpers
6 | alias Gust.DAG.Cron
7 | alias Gust.DAG.Scheduler.Worker
8 |
9 | import Mox
10 |
11 | setup :verify_on_exit!
12 | setup :set_mox_from_context
13 |
14 | describe "handle_call/2 when {:load_dags, dags} is passed" do
15 | setup do
16 | start_link_supervised!(Cron)
17 | name = "blink_182"
18 | dag = dag_fixture(%{name: name})
19 | dag_folder = make_rand_dir!("dags")
20 |
21 | %{dag: dag, folder: dag_folder}
22 | end
23 |
24 | test "do not add task for dag with errors", %{dag: dag} do
25 | name = dag.name
26 |
27 | dag_def = %Gust.DAG.Definition{
28 | mod: MockDagMod,
29 | name: name,
30 | error: %CompileError{description: "opsss"},
31 | options: [schedule: "* * * * *"]
32 | }
33 |
34 | start_link_supervised!({Gust.DAG.Scheduler.Worker, {}})
35 |
36 | Worker.schedule(%{dag.id => dag_def})
37 |
38 | assert Cron.find_job(String.to_atom(name)) == nil
39 | end
40 |
41 | test "do not add errored dag", %{dag: dag} do
42 | name = dag.name
43 |
44 | start_link_supervised!({Gust.DAG.Scheduler.Worker, {}})
45 |
46 | Worker.schedule(%{dag.id => {:error, {}}})
47 |
48 | assert Cron.find_job(String.to_atom(name)) == nil
49 | end
50 |
51 | test "do not add task for dag without scheduler", %{dag: dag} do
52 | name = dag.name
53 |
54 | dag_def = %Gust.DAG.Definition{
55 | mod: MockDagMod,
56 | name: name,
57 | options: []
58 | }
59 |
60 | start_link_supervised!({Gust.DAG.Scheduler.Worker, {}})
61 |
62 | Worker.schedule(%{dag.id => {:ok, dag_def}})
63 |
64 | assert Cron.find_job(String.to_atom(name)) == nil
65 | end
66 |
67 | test "add task for dag with scheduler", %{dag: dag} do
68 | name = dag.name
69 |
70 | dag_def = %Gust.DAG.Definition{
71 | mod: MockDagMod,
72 | name: name,
73 | error: %{},
74 | options: [schedule: "* * * * *"]
75 | }
76 |
77 | start_link_supervised!({Gust.DAG.Scheduler.Worker, {}})
78 | atom_name = String.to_atom(name)
79 | schedule = ~e[* * * * *]
80 |
81 | start_dag_task = {
82 | Gust.DAG.RunRestarter,
83 | :start_dag,
84 | [dag.id]
85 | }
86 |
87 | [:ok] = Worker.schedule(%{dag.id => {:ok, dag_def}})
88 |
89 | assert %Quantum.Job{name: ^atom_name, schedule: ^schedule, task: ^start_dag_task} =
90 | Cron.find_job(String.to_atom(name))
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/apps/gust/test/dsl_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DSLTest do
2 | use Gust.DataCase
3 |
4 | test "use macro with schedule option" do
5 | dag_code = """
6 | defmodule MyValidDagEmpty do
7 | use Gust.DSL, schedule: "0 17 * * *"
8 |
9 | task :hi do
10 | # saying hi
11 | 1 + 1
12 | end
13 |
14 | end
15 | """
16 |
17 | [{mod, _bin}] = Code.compile_string(dag_code)
18 |
19 | assert mod.__dag_options__() == [schedule: "0 17 * * *"]
20 |
21 | :code.purge(mod)
22 | :code.delete(mod)
23 | end
24 |
25 | test "task macro without opts" do
26 | dag_code = """
27 | defmodule MyValidDagEmpty do
28 | use Gust.DSL
29 |
30 | task :hi do
31 | # saying hi
32 | 1 + 1
33 | end
34 |
35 | end
36 | """
37 |
38 | [{mod, _bin}] = Code.compile_string(dag_code)
39 |
40 | assert mod.__dag_tasks__() == [{:hi, []}]
41 |
42 | :code.purge(mod)
43 | :code.delete(mod)
44 | end
45 |
46 | test "task macro with context option" do
47 | run_id = 1234
48 | ctx = %{run_id: 1234}
49 |
50 | dag_code = """
51 | defmodule MyValidDagEmpty do
52 | use Gust.DSL
53 |
54 | task :hi, ctx: %{run_id: run_id} do
55 | run_id
56 | end
57 |
58 | end
59 | """
60 |
61 | [{mod, _bin}] = Code.compile_string(dag_code)
62 | assert mod.__dag_tasks__() == [{:hi, []}]
63 |
64 | # credo:disable-for-next-line Credo.Check.Refactor.Apply
65 | assert apply(mod, :hi, [ctx]) == run_id
66 |
67 | :code.purge(mod)
68 | :code.delete(mod)
69 | end
70 |
71 | test "task macro with store_result option" do
72 | dag_code = """
73 | defmodule MyValidDagEmpty do
74 | use Gust.DSL
75 |
76 | task :hi, store_result: true do
77 | # saying hi
78 | 1 + 1
79 | end
80 |
81 | end
82 | """
83 |
84 | [{mod, _bin}] = Code.compile_string(dag_code)
85 |
86 | assert mod.__dag_tasks__() == [{:hi, [store_result: true]}]
87 |
88 | :code.purge(mod)
89 | :code.delete(mod)
90 | end
91 |
92 | test "task macro without downstream opts" do
93 | dag_code = """
94 | defmodule MyValidDagEmpty do
95 | use Gust.DSL
96 |
97 | task :bye do
98 | # saying bye
99 | 2 + 2
100 | end
101 |
102 | task :hi, downstreams: [:bye] do
103 | # saying hi
104 | 1 + 1
105 | end
106 |
107 | end
108 | """
109 |
110 | [{mod, _bin}] = Code.compile_string(dag_code)
111 |
112 | assert mod.__dag_tasks__() == [{:hi, [downstreams: [:bye]]}, {:bye, []}]
113 |
114 | :code.purge(mod)
115 | :code.delete(mod)
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/live/secret_live/index.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | <.header>
4 | Listing Secrets
5 | <:actions>
6 | <.link patch={~p"/secrets/new"}>
7 | <.button>New Secret
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | <.table id="secrets" rows={@streams.secrets}>
16 | <:col :let={{_id, secret}} label="Name">
17 | {secret.name}
18 |
19 |
20 | <:col :let={{_id, secret}} label="Type">
21 | {secret.value_type}
22 |
23 |
24 | <:action :let={{_id, secret}}>
25 | <.link patch={~p"/secrets/#{secret}/edit"} class="btn btn-sm btn-soft btn-primary">
26 | Edit
27 |
28 |
29 |
30 | <:action :let={{id, secret}}>
31 | <.link
32 | phx-click={JS.push("delete", value: %{id: secret.id}) |> hide("##{id}")}
33 | data-confirm="Are you sure?"
34 | class="btn btn-sm btn-soft btn-error"
35 | >
36 | Delete
37 |
38 |
39 |
40 |
41 |
42 |
43 | <%= if @live_action in [:new, :edit] do %>
44 | <.form :let={f} for={@form} id="secret-form" phx-change="validate" phx-submit="save">
45 |
46 |
47 |
48 |
51 | <.input field={f[:name]} type="text" class="input input-bordered w-full" />
52 |
53 |
54 |
55 |
58 | <.input
59 | field={f[:value_type]}
60 | type="select"
61 | options={[:string, :json]}
62 | class="select select-bordered w-full"
63 | />
64 |
65 |
66 |
67 |
70 | <.input
71 | field={f[:value]}
72 | type="textarea"
73 | class="textarea textarea-bordered w-full h-24"
74 | />
75 |
76 |
77 |
78 | <.button class="btn btn-primary" phx-disable-with="Saving...">Save Secret
79 |
80 |
81 |
82 |
83 | <% end %>
84 |
85 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web/components/dag_run_components.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.DagRunComponents do
2 | @moduledoc false
3 |
4 | use Phoenix.Component
5 | use Gettext, backend: GustWeb.Gettext
6 |
7 | attr :status, :atom, required: true
8 | attr :rest, :global, doc: "data-testid, etc."
9 |
10 | def status_badge(assigns) do
11 | ~H"""
12 | "badge-success"
19 | :failed -> "badge-error"
20 | :upstream_failed -> "badge-warning"
21 | _ -> "badge-info"
22 | end
23 | ]}
24 | >
25 | {@status}
26 |
27 | """
28 | end
29 |
30 | attr :level, :string, required: true
31 |
32 | def log_badge(assigns) do
33 | ~H"""
34 | "badge-info"
39 | "info" -> "badge-info"
40 | "warn" -> "badge-warning"
41 | "error" -> "badge-error"
42 | end
43 | ]}>
44 | {@level}
45 |
46 | """
47 | end
48 |
49 | attr :id, :string, required: true
50 | attr :selected, :string
51 | attr :status, :string
52 |
53 | def task_cell(assigns) do
54 | assigns =
55 | assign_new(assigns, :classes, fn ->
56 | base_classes =
57 | if assigns[:status], do: ["status-#{assigns[:status]}", "active"], else: ["status-none"]
58 |
59 | classes = base_classes ++ if assigns[:selected], do: ["selected"], else: []
60 |
61 | Enum.join(classes, " ")
62 | end)
63 |
64 | ~H"""
65 |
69 |
70 | """
71 | end
72 |
73 | attr :run_id, :string, required: true
74 | attr :ran_tasks, :list, required: true
75 | attr :name, :string, required: true
76 | attr :selected_task, :map, required: true
77 | attr :navigate, :string, required: true
78 | attr :rest, :global
79 |
80 | def interactive_task_cell(assigns) do
81 | assigns =
82 | assign_new(assigns, :current_task_ran, fn ->
83 | assigns[:ran_tasks] |> Enum.find(&(&1.name == assigns[:name]))
84 | end)
85 |
86 | if assigns[:current_task_ran] do
87 | ~H"""
88 | <.link navigate={@navigate} {@rest}>
89 | <.task_cell
90 | selected={if @selected_task, do: @selected_task.id == @current_task_ran.id, else: false}
91 | status={@current_task_ran.status}
92 | id={"#{@name}-at-run-#{@run_id}"}
93 | />
94 |
95 | """
96 | else
97 | ~H"""
98 | <.task_cell id={"#{@name}-at-run-#{@run_id}"} />
99 | """
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/apps/gust/test/flows/secret_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gust.Flows.SecretTest do
2 | use ExUnit.Case, async: true
3 | alias Gust.Flows.Secret
4 |
5 | describe "name format validation" do
6 | test "accepts valid names" do
7 | valid_names = ["MY_SECRET", "HELLO_WORLD", "SECRET_123"]
8 |
9 | for name <- valid_names do
10 | changeset = Secret.changeset(%Secret{}, %{name: name, value: "abc", value_type: :string})
11 |
12 | assert changeset.valid?, """
13 | Expected #{name} to be valid, but got errors: #{inspect(changeset.errors)}
14 | """
15 | end
16 | end
17 |
18 | test "rejects invalid names with correct error message" do
19 | invalid_names = [
20 | {"my_secret", "must be uppercase with underscores"},
21 | {"MySecret", "must be uppercase with underscores"},
22 | {"MY-SECRET", "must be uppercase with underscores"},
23 | {"MY SECRET", "must be uppercase with underscores"},
24 | {"", "can't be blank"}
25 | ]
26 |
27 | for {name, expected_msg} <- invalid_names do
28 | changeset = Secret.changeset(%Secret{}, %{name: name, value: "abc", value_type: :string})
29 | refute changeset.valid?, "Expected #{inspect(name)} to be invalid"
30 |
31 | {field, {msg, opts}} = hd(changeset.errors)
32 |
33 | assert field == :name
34 | assert msg == expected_msg
35 |
36 | if expected_msg == "must be uppercase with underscores" do
37 | assert opts[:validation] == :format
38 | end
39 | end
40 | end
41 | end
42 |
43 | describe "required fields" do
44 | test "rejects missing required fields" do
45 | changeset = Secret.changeset(%Secret{}, %{})
46 | refute changeset.valid?
47 | assert Keyword.keys(changeset.errors) == [:name, :value, :value_type]
48 | end
49 | end
50 |
51 | describe "JSON value validation" do
52 | test "accepts valid JSON when value_type is :json" do
53 | json_value = ~s({"foo": "bar"})
54 |
55 | changeset =
56 | Secret.changeset(%Secret{}, %{name: "VALID_JSON", value: json_value, value_type: :json})
57 |
58 | assert changeset.valid?, "Expected valid JSON to be accepted"
59 | end
60 |
61 | test "rejects invalid JSON when value_type is :json" do
62 | invalid_json = ~s({foo: bar})
63 |
64 | changeset =
65 | Secret.changeset(%Secret{}, %{
66 | name: "INVALID_JSON",
67 | value: invalid_json,
68 | value_type: :json
69 | })
70 |
71 | refute changeset.valid?
72 | assert {:value, {"must be valid JSON", []}} = hd(changeset.errors)
73 | end
74 |
75 | test "rejects nil JSON value" do
76 | changeset =
77 | Secret.changeset(%Secret{}, %{name: "EMPTY_JSON", value: nil, value_type: :json})
78 |
79 | refute changeset.valid?
80 | assert {:value, {"it cannot be empty", []}} = hd(changeset.errors)
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/live/run_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.RunLiveTest do
2 | alias Gust.Flows
3 | use GustWeb.ConnCase
4 |
5 | import Phoenix.LiveViewTest
6 | import Gust.FlowsFixtures
7 | import Mox
8 |
9 | setup :verify_on_exit!
10 |
11 | describe "Index" do
12 | setup %{conn: conn} do
13 | dag = dag_fixture(%{name: "dag_with_runs"})
14 | run = run_fixture(%{dag_id: dag.id})
15 |
16 | %{conn: conn, run: run, dag: dag}
17 | end
18 |
19 | test "list runs", %{conn: conn, dag: dag, run: run} do
20 | {:ok, _index_live, html} =
21 | live(conn, ~p"/dags/#{dag.name}/runs?page_size=30&page=1")
22 |
23 | assert html =~ "Listing Runs"
24 | assert html =~ dag.name
25 | assert html =~ to_string(run.status)
26 | assert html =~ to_string(run.inserted_at)
27 | assert html =~ to_string(run.updated_at)
28 | assert html =~ to_string(run.id)
29 | end
30 |
31 | test "list runs paged", %{conn: conn, dag: dag, run: _first_run} do
32 | page_size = 3
33 |
34 | run_fixture(%{dag_id: dag.id})
35 | prev_page_run = run_fixture(%{dag_id: dag.id})
36 |
37 | current_page_run = run_fixture(%{dag_id: dag.id})
38 |
39 | {:ok, index_live, _html} =
40 | live(conn, ~p"/dags/#{dag.name}/runs?page_size=#{page_size}&page=2")
41 |
42 | assert index_live |> has_element?("#runs-#{current_page_run.id}")
43 | refute index_live |> has_element?("#runs-#{prev_page_run.id}")
44 |
45 | assert index_live |> has_element?("#pages option[value='2']:checked")
46 | refute index_live |> has_element?("#pages option[value='3']")
47 |
48 | index_live
49 | |> element("#page-select")
50 | |> render_change(%{"_target" => "page", "page" => "1"})
51 |
52 | assert_patch index_live, ~p"/dags/#{dag.name}/runs?page_size=3&page=1"
53 | end
54 |
55 | test "deletes run in listing", %{conn: conn, dag: dag, run: run} do
56 | {:ok, index_live, _html} = live(conn, ~p"/dags/#{dag.name}/runs?page_size=30&page=1")
57 |
58 | assert index_live |> element("#runs-#{run.id} a", "Delete") |> render_click()
59 | refute has_element?(index_live, "#runs-#{run.id}")
60 | end
61 |
62 | test "new dag run was created", %{conn: conn, dag: dag} do
63 | {:ok, index_live, _html} =
64 | live(conn, ~p"/dags/#{dag.name}/runs?page_size=30&page=1")
65 |
66 | new_run = run_fixture(%{dag_id: dag.id})
67 |
68 | Gust.PubSub.broadcast_run_started(dag.id, new_run.id)
69 |
70 | assert index_live |> has_element?("#runs-#{new_run.id}")
71 | end
72 |
73 | test "run is updated", %{conn: conn, dag: dag, run: run} do
74 | {:ok, index_live, _html} =
75 | live(conn, ~p"/dags/#{dag.name}/runs?page_size=30&page=1")
76 |
77 | Flows.update_run_status(run, :succeeded)
78 |
79 | Gust.PubSub.broadcast_run_status(run.id, :succeeded)
80 |
81 | badge_html =
82 | index_live |> element("#runs-#{run.id} [data-testid='status-badge']") |> render()
83 |
84 | assert badge_html =~ "succeeded"
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/live/dag_summary_live_component_test.exs:
--------------------------------------------------------------------------------
1 | # TODO: test schedule change
2 | defmodule GustWeb.DagSummaryLiveComponentTest do
3 | alias Gust.Flows
4 | use GustWeb.ConnCase
5 | import Phoenix.LiveViewTest
6 | import Gust.FlowsFixtures
7 |
8 | require GustWeb.LiveComponentTest
9 | import GustWeb.LiveComponentTest
10 | import Mox
11 |
12 | setup :verify_on_exit!
13 |
14 | setup do
15 | dag = dag_fixture(%{name: "my_dag"})
16 | Gust.Flows.get_dag!(dag.id)
17 |
18 | dag_def = %Gust.DAG.Definition{
19 | name: "my_dag",
20 | options: [schedule: "* * * *"]
21 | }
22 |
23 | %{dag: dag, dag_def: dag_def}
24 | end
25 |
26 | describe "when dag_define has error" do
27 | test "cannot be triggered", %{conn: conn, dag: dag, dag_def: dag_def} do
28 | error_description = "Ops, something went kaboom"
29 | message = "check your code!"
30 |
31 | dag_def = %{
32 | dag_def
33 | | messages: [%{message: message}],
34 | error: %CompileError{description: error_description}
35 | }
36 |
37 | {:ok, dag_summary, html} =
38 | live_component_isolated(conn, GustWeb.DagSummaryComponent, %{
39 | id: dag.id,
40 | dag: dag,
41 | dag_def: dag_def
42 | })
43 |
44 | assert dag_summary |> has_element?("#trigger-dag-run-#{dag.id}:disabled")
45 |
46 | assert html =~ error_description
47 | assert html =~ message
48 | end
49 | end
50 |
51 | test "re-enabled dag", %{conn: conn, dag: dag, dag_def: dag_def} do
52 | dag_id = dag.id
53 | {:ok, dag} = Flows.toggle_enabled(dag)
54 |
55 | GustWeb.DAGRunRestarterMock |> expect(:restart_enqueued, fn ^dag_id -> nil end)
56 |
57 | {:ok, dag_summary, _html} =
58 | live_component_isolated(conn, GustWeb.DagSummaryComponent, %{
59 | id: dag_id,
60 | dag: dag,
61 | dag_def: dag_def
62 | })
63 |
64 | dag_summary |> element("[name='dag-enabling-toggle-#{dag.id}']") |> render_click()
65 |
66 | assert Gust.Flows.get_dag!(dag.id).enabled == true
67 | end
68 |
69 | test "toggle dag enabled", %{conn: conn, dag: dag, dag_def: dag_def} do
70 | dag_id = dag.id
71 |
72 | {:ok, dag_summary, _html} =
73 | live_component_isolated(conn, GustWeb.DagSummaryComponent, %{
74 | id: dag_id,
75 | dag: dag,
76 | dag_def: dag_def
77 | })
78 |
79 | dag_summary |> element("[name='dag-enabling-toggle-#{dag.id}']:checked") |> render_click()
80 |
81 | assert Gust.Flows.get_dag!(dag.id).enabled == false
82 | end
83 |
84 | test "link to dag runs", %{conn: conn, dag: dag, dag_def: dag_def} do
85 | {:ok, dag_summary, _html} =
86 | live_component_isolated(conn, GustWeb.DagSummaryComponent, %{
87 | dag: dag,
88 | dag_def: dag_def
89 | })
90 |
91 | assert dag_summary |> has_element?(~s{[href="/dags/#{dag.name}/dashboard"]})
92 | dag_summary |> element(~s{[href="/dags/#{dag.name}/dashboard"]}) |> render_click()
93 |
94 | assert_redirect dag_summary, "/dags/#{dag.name}/dashboard"
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/apps/gust_web/lib/gust_web.ex:
--------------------------------------------------------------------------------
1 | defmodule GustWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use GustWeb, :controller
9 | use GustWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller, formats: [:html, :json]
42 |
43 | use Gettext, backend: GustWeb.Gettext
44 |
45 | import Plug.Conn
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def live_view do
52 | quote do
53 | use Phoenix.LiveView
54 |
55 | unquote(html_helpers())
56 | end
57 | end
58 |
59 | def live_component do
60 | quote do
61 | use Phoenix.LiveComponent
62 |
63 | unquote(html_helpers())
64 | end
65 | end
66 |
67 | def html do
68 | quote do
69 | use Phoenix.Component
70 |
71 | # Import convenience functions from controllers
72 | import Phoenix.Controller,
73 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
74 |
75 | # Include general helpers for rendering HTML
76 | unquote(html_helpers())
77 | end
78 | end
79 |
80 | defp html_helpers do
81 | quote do
82 | # Translation
83 | use Gettext, backend: GustWeb.Gettext
84 |
85 | # HTML escaping functionality
86 | import Phoenix.HTML
87 | # Core UI components
88 | import GustWeb.CoreComponents
89 |
90 | # DAG run components
91 | import GustWeb.DagRunComponents
92 |
93 | # Common modules used in templates
94 | alias GustWeb.Layouts
95 | alias Phoenix.LiveView.JS
96 |
97 | # Routes generation with the ~p sigil
98 | unquote(verified_routes())
99 | end
100 | end
101 |
102 | def verified_routes do
103 | quote do
104 | use Phoenix.VerifiedRoutes,
105 | endpoint: GustWeb.Endpoint,
106 | router: GustWeb.Router,
107 | statics: GustWeb.static_paths()
108 | end
109 | end
110 |
111 | @doc """
112 | When used, dispatch to the appropriate controller/view/etc.
113 | """
114 | defmacro __using__(which) when is_atom(which) do
115 | apply(__MODULE__, which, [])
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/apps/gust_web/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should have %{count} item(s)"
54 | msgid_plural "should have %{count} item(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should be %{count} character(s)"
59 | msgid_plural "should be %{count} character(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be %{count} byte(s)"
64 | msgid_plural "should be %{count} byte(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at least %{count} character(s)"
74 | msgid_plural "should be at least %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should be at least %{count} byte(s)"
79 | msgid_plural "should be at least %{count} byte(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | msgid "should have at most %{count} item(s)"
84 | msgid_plural "should have at most %{count} item(s)"
85 | msgstr[0] ""
86 | msgstr[1] ""
87 |
88 | msgid "should be at most %{count} character(s)"
89 | msgid_plural "should be at most %{count} character(s)"
90 | msgstr[0] ""
91 | msgstr[1] ""
92 |
93 | msgid "should be at most %{count} byte(s)"
94 | msgid_plural "should be at most %{count} byte(s)"
95 | msgstr[0] ""
96 | msgstr[1] ""
97 |
98 | ## From Ecto.Changeset.validate_number/3
99 | msgid "must be less than %{number}"
100 | msgstr ""
101 |
102 | msgid "must be greater than %{number}"
103 | msgstr ""
104 |
105 | msgid "must be less than or equal to %{number}"
106 | msgstr ""
107 |
108 | msgid "must be greater than or equal to %{number}"
109 | msgstr ""
110 |
111 | msgid "must be equal to %{number}"
112 | msgstr ""
113 |
--------------------------------------------------------------------------------
/apps/gust_web/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 | ## From Ecto.Changeset.cast/4
11 | msgid "can't be blank"
12 | msgstr ""
13 |
14 | ## From Ecto.Changeset.unique_constraint/3
15 | msgid "has already been taken"
16 | msgstr ""
17 |
18 | ## From Ecto.Changeset.put_change/3
19 | msgid "is invalid"
20 | msgstr ""
21 |
22 | ## From Ecto.Changeset.validate_acceptance/3
23 | msgid "must be accepted"
24 | msgstr ""
25 |
26 | ## From Ecto.Changeset.validate_format/3
27 | msgid "has invalid format"
28 | msgstr ""
29 |
30 | ## From Ecto.Changeset.validate_subset/3
31 | msgid "has an invalid entry"
32 | msgstr ""
33 |
34 | ## From Ecto.Changeset.validate_exclusion/3
35 | msgid "is reserved"
36 | msgstr ""
37 |
38 | ## From Ecto.Changeset.validate_confirmation/3
39 | msgid "does not match confirmation"
40 | msgstr ""
41 |
42 | ## From Ecto.Changeset.no_assoc_constraint/3
43 | msgid "is still associated with this entry"
44 | msgstr ""
45 |
46 | msgid "are still associated with this entry"
47 | msgstr ""
48 |
49 | ## From Ecto.Changeset.validate_length/3
50 | msgid "should have %{count} item(s)"
51 | msgid_plural "should have %{count} item(s)"
52 | msgstr[0] ""
53 | msgstr[1] ""
54 |
55 | msgid "should be %{count} character(s)"
56 | msgid_plural "should be %{count} character(s)"
57 | msgstr[0] ""
58 | msgstr[1] ""
59 |
60 | msgid "should be %{count} byte(s)"
61 | msgid_plural "should be %{count} byte(s)"
62 | msgstr[0] ""
63 | msgstr[1] ""
64 |
65 | msgid "should have at least %{count} item(s)"
66 | msgid_plural "should have at least %{count} item(s)"
67 | msgstr[0] ""
68 | msgstr[1] ""
69 |
70 | msgid "should be at least %{count} character(s)"
71 | msgid_plural "should be at least %{count} character(s)"
72 | msgstr[0] ""
73 | msgstr[1] ""
74 |
75 | msgid "should be at least %{count} byte(s)"
76 | msgid_plural "should be at least %{count} byte(s)"
77 | msgstr[0] ""
78 | msgstr[1] ""
79 |
80 | msgid "should have at most %{count} item(s)"
81 | msgid_plural "should have at most %{count} item(s)"
82 | msgstr[0] ""
83 | msgstr[1] ""
84 |
85 | msgid "should be at most %{count} character(s)"
86 | msgid_plural "should be at most %{count} character(s)"
87 | msgstr[0] ""
88 | msgstr[1] ""
89 |
90 | msgid "should be at most %{count} byte(s)"
91 | msgid_plural "should be at most %{count} byte(s)"
92 | msgstr[0] ""
93 | msgstr[1] ""
94 |
95 | ## From Ecto.Changeset.validate_number/3
96 | msgid "must be less than %{number}"
97 | msgstr ""
98 |
99 | msgid "must be greater than %{number}"
100 | msgstr ""
101 |
102 | msgid "must be less than or equal to %{number}"
103 | msgstr ""
104 |
105 | msgid "must be greater than or equal to %{number}"
106 | msgstr ""
107 |
108 | msgid "must be equal to %{number}"
109 | msgstr ""
110 |
--------------------------------------------------------------------------------
/apps/gust_web/test/gust_web/live/secret_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GustWeb.SecretLiveTest do
2 | use GustWeb.ConnCase
3 |
4 | import Phoenix.LiveViewTest
5 | import Gust.FlowsFixtures
6 |
7 | @create_attrs %{name: "SOME_SECRET", value: "some value", value_type: :string}
8 | @update_attrs %{name: "SOME_SECRET", value: "some updated value", value_type: :string}
9 | @invalid_attrs %{name: nil, value: nil}
10 | @invalid_json %{name: "SOME_SECRET", value: "invalid json value", value_type: :json}
11 |
12 | defp create_secret(_) do
13 | secret = secret_fixture()
14 | %{secret: secret}
15 | end
16 |
17 | describe "Index" do
18 | setup [:create_secret]
19 |
20 | test "lists all secrets", %{conn: conn, secret: secret} do
21 | {:ok, _index_live, html} = live(conn, ~p"/secrets")
22 |
23 | assert html =~ "Listing Secrets"
24 | assert html =~ secret.name
25 | end
26 |
27 | test "saves new secret", %{conn: conn} do
28 | {:ok, index_live, _html} = live(conn, ~p"/secrets")
29 |
30 | assert index_live |> element("a", "New Secret") |> render_click() =~
31 | "New Secret"
32 |
33 | assert_patch(index_live, ~p"/secrets/new")
34 |
35 | assert index_live
36 | |> form("#secret-form", secret: @invalid_attrs)
37 | |> render_change() =~ "can't be blank"
38 |
39 | assert index_live
40 | |> form("#secret-form", secret: @invalid_json)
41 | |> render_change() =~ "must be valid JSON"
42 |
43 | assert index_live
44 | |> form("#secret-form", secret: @create_attrs)
45 | |> render_submit()
46 |
47 | assert_patch(index_live, ~p"/secrets")
48 |
49 | html = render(index_live)
50 | assert html =~ "Secret created successfully"
51 | assert html =~ "SOME_NAME"
52 | end
53 |
54 | test "updates secret in listing", %{conn: conn, secret: secret} do
55 | {:ok, index_live, _html} = live(conn, ~p"/secrets")
56 |
57 | assert index_live |> element("#secrets-#{secret.id} a", "Edit") |> render_click() =~
58 | "Edit"
59 |
60 | assert_patch(index_live, ~p"/secrets/#{secret}/edit")
61 |
62 | secret_value_html = index_live |> element("#secret-form_value") |> render()
63 |
64 | case Regex.run(~r/