Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 42 additions & 23 deletions lib/lightning/workflow_versions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,33 @@ defmodule Lightning.WorkflowVersions do
@doc """
Generates a deterministic hash for a workflow based on its structure.

Hashes the string produced by `canonical_form/1` with SHA-256 and truncates
the result to 12 lowercase hex characters.

## Parameters
* `workflow` — the workflow struct (or equivalent map) to hash

## Returns
* a 12-character lowercase hex string

## Examples

iex> WorkflowVersions.generate_hash(workflow)
"a1b2c3d4e5f6"
"""
@spec generate_hash(Workflow.t() | map()) :: binary()
def generate_hash(workflow) do
:crypto.hash(:sha256, canonical_form(workflow))
|> Base.encode16(case: :lower)
|> binary_part(0, 12)
end

@doc """
Builds the deterministic canonical string for a workflow — the exact input
that `generate_hash/1` digests.

Useful for debugging what's fed into the hash.

Algorithm:
- Create a list
- Add the workflow name to the start of the list
Expand All @@ -213,30 +240,27 @@ defmodule Lightning.WorkflowVersions do
- Add only the field VALUES to the list (keys are excluded)
- Numeric values (e.g., positions) are rounded up to integers
- Join the list into a string, no separator
- Hash the string with SHA 256
- Truncate the resulting string to 12 characters

## Parameters
* `workflow` — the workflow struct to hash
* `workflow` — the workflow struct (or equivalent map) to serialize

## Returns
* A 12-character lowercase hex string
* the joined canonical string

## Examples

iex> WorkflowVersions.generate_hash(workflow)
"a1b2c3d4e5f6"
iex> WorkflowVersions.canonical_form(workflow)
"My Workflow{...}webhook..."
"""
@spec generate_hash(Workflow.t() | map()) :: binary()
def generate_hash(%Workflow{} = workflow) do
workflow = Repo.preload(workflow, [:jobs, :edges, :triggers])

@spec canonical_form(Workflow.t() | map()) :: binary()
def canonical_form(%Workflow{} = workflow) do
workflow
|> Repo.preload([:jobs, :edges, :triggers])
|> Map.from_struct()
|> generate_hash()
|> canonical_form()
end

def generate_hash(%{} = workflow) do
def canonical_form(%{} = workflow) do
workflow_keys = [:name, :positions]

job_keys = [
Expand Down Expand Up @@ -314,17 +338,12 @@ defmodule Lightning.WorkflowVersions do
acc ++ hash_list
end)

joined_data =
Enum.join([
workflow_hash_list,
triggers_hash_list,
jobs_hash_list,
edges_hash_list
])

:crypto.hash(:sha256, joined_data)
|> Base.encode16(case: :lower)
|> binary_part(0, 12)
Enum.join([
workflow_hash_list,
triggers_hash_list,
jobs_hash_list,
edges_hash_list
])
end

defp serialize_value(%WebhookResponseConfig{} = val) do
Expand Down
82 changes: 82 additions & 0 deletions lib/mix/tasks/gen_workflow_hash.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
defmodule Mix.Tasks.Lightning.GenWorkflowHash do
@shortdoc "Generate the version hash for a workflow"

@moduledoc """
Generates a deterministic version hash for an existing workflow.

## Usage

mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash]

## Arguments

* `WORKFLOW_UUID` - The UUID of the workflow to hash

## Options

* `--no-hash` - Print the joined pre-hash string instead of the hash.
Useful for debugging what's fed into the hash.

## Examples

mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000
mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000 --no-hash
"""
use Mix.Task

alias Lightning.Workflows
alias Lightning.WorkflowVersions

require Logger

@impl Mix.Task
def run(args) do
{opts, positional, invalid} =
OptionParser.parse(args, strict: [hash: :boolean])

cond do
length(invalid) > 0 ->
invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end)
Mix.raise("Unknown option(s): #{invalid_opts}")

length(positional) != 1 ->
Mix.raise("""
Expected exactly 1 argument: WORKFLOW_UUID

Usage:
mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash]
""")

true ->
[workflow_id] = positional
start_repo()
print_hash(workflow_id, opts)
end
end

defp start_repo do
Logger.configure(level: :error)
Mix.Task.run("app.config")
{:ok, _} = Application.ensure_all_started(:ecto_sql)

case Lightning.Repo.start_link(pool_size: 1) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
end
end

defp print_hash(workflow_id, opts) do
case Workflows.get_workflow(workflow_id) do
nil ->
Mix.raise("Workflow #{workflow_id} not found")

workflow ->
if Keyword.get(opts, :hash, true) do
WorkflowVersions.generate_hash(workflow)
else
WorkflowVersions.canonical_form(workflow)
end
|> IO.puts()
end
end
end
56 changes: 56 additions & 0 deletions test/mix/tasks/gen_workflow_hash_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Mix.Tasks.Lightning.GenWorkflowHashTest do
use Lightning.DataCase

import ExUnit.CaptureIO

alias Lightning.WorkflowVersions
alias Mix.Tasks.Lightning.GenWorkflowHash

defp run(args) do
capture_io(fn -> GenWorkflowHash.run(args) end) |> String.trim()
end

describe "run/1" do
test "prints the 12-char hash for an existing workflow" do
workflow = build_workflow()

output = run([workflow.id])

assert output == WorkflowVersions.generate_hash(workflow)
assert String.match?(output, ~r/^[a-f0-9]{12}$/)
end

test "with --no-hash prints the canonical pre-hash string" do
workflow = build_workflow()

output = run([workflow.id, "--no-hash"])

assert output == WorkflowVersions.canonical_form(workflow)
# The canonical form is the un-digested input, so it is not a bare hash.
refute String.match?(output, ~r/^[a-f0-9]{12}$/)
end

test "raises when the workflow does not exist" do
id = Ecto.UUID.generate()

assert_raise Mix.Error, "Workflow #{id} not found", fn ->
run([id])
end
end
end

defp build_workflow do
workflow = insert(:workflow, name: "Hashable Workflow")
trigger = insert(:trigger, workflow: workflow, type: :webhook)
job = insert(:job, workflow: workflow, name: "Process")

insert(:edge,
workflow: workflow,
source_trigger: trigger,
target_job: job,
condition_type: :always
)

Repo.preload(workflow, [:triggers, :jobs, :edges])
end
end