Skip to content

Commit 0a2b19b

Browse files
Version hash utils (#4758)
* add mix task to generate version hash * optional flag to skip the hash * Split generate_hash into generate_hash and canonical_form Replace the generate_hash/2 `hash: false` option with a dedicated canonical_form/1 function that returns the pre-hash joined string. A function named generate_hash that is asked not to hash read as contradictory; the two concerns are now separate public functions. Update the gen_workflow_hash task to route --no-hash through canonical_form/1, and make its repo bootstrap tolerant of an already-running repo so the task can be tested. Add task coverage. * make credo happy --------- Co-authored-by: Frank Midigo <midigofrank@gmail.com>
1 parent e7121ba commit 0a2b19b

3 files changed

Lines changed: 180 additions & 23 deletions

File tree

lib/lightning/workflow_versions.ex

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,33 @@ defmodule Lightning.WorkflowVersions do
204204
@doc """
205205
Generates a deterministic hash for a workflow based on its structure.
206206
207+
Hashes the string produced by `canonical_form/1` with SHA-256 and truncates
208+
the result to 12 lowercase hex characters.
209+
210+
## Parameters
211+
* `workflow` — the workflow struct (or equivalent map) to hash
212+
213+
## Returns
214+
* a 12-character lowercase hex string
215+
216+
## Examples
217+
218+
iex> WorkflowVersions.generate_hash(workflow)
219+
"a1b2c3d4e5f6"
220+
"""
221+
@spec generate_hash(Workflow.t() | map()) :: binary()
222+
def generate_hash(workflow) do
223+
:crypto.hash(:sha256, canonical_form(workflow))
224+
|> Base.encode16(case: :lower)
225+
|> binary_part(0, 12)
226+
end
227+
228+
@doc """
229+
Builds the deterministic canonical string for a workflow — the exact input
230+
that `generate_hash/1` digests.
231+
232+
Useful for debugging what's fed into the hash.
233+
207234
Algorithm:
208235
- Create a list
209236
- Add the workflow name to the start of the list
@@ -213,30 +240,27 @@ defmodule Lightning.WorkflowVersions do
213240
- Add only the field VALUES to the list (keys are excluded)
214241
- Numeric values (e.g., positions) are rounded up to integers
215242
- Join the list into a string, no separator
216-
- Hash the string with SHA 256
217-
- Truncate the resulting string to 12 characters
218243
219244
## Parameters
220-
* `workflow` — the workflow struct to hash
245+
* `workflow` — the workflow struct (or equivalent map) to serialize
221246
222247
## Returns
223-
* A 12-character lowercase hex string
248+
* the joined canonical string
224249
225250
## Examples
226251
227-
iex> WorkflowVersions.generate_hash(workflow)
228-
"a1b2c3d4e5f6"
252+
iex> WorkflowVersions.canonical_form(workflow)
253+
"My Workflow{...}webhook..."
229254
"""
230-
@spec generate_hash(Workflow.t() | map()) :: binary()
231-
def generate_hash(%Workflow{} = workflow) do
232-
workflow = Repo.preload(workflow, [:jobs, :edges, :triggers])
233-
255+
@spec canonical_form(Workflow.t() | map()) :: binary()
256+
def canonical_form(%Workflow{} = workflow) do
234257
workflow
258+
|> Repo.preload([:jobs, :edges, :triggers])
235259
|> Map.from_struct()
236-
|> generate_hash()
260+
|> canonical_form()
237261
end
238262

239-
def generate_hash(%{} = workflow) do
263+
def canonical_form(%{} = workflow) do
240264
workflow_keys = [:name, :positions]
241265

242266
job_keys = [
@@ -314,17 +338,12 @@ defmodule Lightning.WorkflowVersions do
314338
acc ++ hash_list
315339
end)
316340

317-
joined_data =
318-
Enum.join([
319-
workflow_hash_list,
320-
triggers_hash_list,
321-
jobs_hash_list,
322-
edges_hash_list
323-
])
324-
325-
:crypto.hash(:sha256, joined_data)
326-
|> Base.encode16(case: :lower)
327-
|> binary_part(0, 12)
341+
Enum.join([
342+
workflow_hash_list,
343+
triggers_hash_list,
344+
jobs_hash_list,
345+
edges_hash_list
346+
])
328347
end
329348

330349
defp serialize_value(%WebhookResponseConfig{} = val) do

lib/mix/tasks/gen_workflow_hash.ex

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
defmodule Mix.Tasks.Lightning.GenWorkflowHash do
2+
@shortdoc "Generate the version hash for a workflow"
3+
4+
@moduledoc """
5+
Generates a deterministic version hash for an existing workflow.
6+
7+
## Usage
8+
9+
mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash]
10+
11+
## Arguments
12+
13+
* `WORKFLOW_UUID` - The UUID of the workflow to hash
14+
15+
## Options
16+
17+
* `--no-hash` - Print the joined pre-hash string instead of the hash.
18+
Useful for debugging what's fed into the hash.
19+
20+
## Examples
21+
22+
mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000
23+
mix lightning.gen_workflow_hash 550e8400-e29b-41d4-a716-446655440000 --no-hash
24+
"""
25+
use Mix.Task
26+
27+
alias Lightning.Workflows
28+
alias Lightning.WorkflowVersions
29+
30+
require Logger
31+
32+
@impl Mix.Task
33+
def run(args) do
34+
{opts, positional, invalid} =
35+
OptionParser.parse(args, strict: [hash: :boolean])
36+
37+
cond do
38+
length(invalid) > 0 ->
39+
invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end)
40+
Mix.raise("Unknown option(s): #{invalid_opts}")
41+
42+
length(positional) != 1 ->
43+
Mix.raise("""
44+
Expected exactly 1 argument: WORKFLOW_UUID
45+
46+
Usage:
47+
mix lightning.gen_workflow_hash WORKFLOW_UUID [--no-hash]
48+
""")
49+
50+
true ->
51+
[workflow_id] = positional
52+
start_repo()
53+
print_hash(workflow_id, opts)
54+
end
55+
end
56+
57+
defp start_repo do
58+
Logger.configure(level: :error)
59+
Mix.Task.run("app.config")
60+
{:ok, _} = Application.ensure_all_started(:ecto_sql)
61+
62+
case Lightning.Repo.start_link(pool_size: 1) do
63+
{:ok, _pid} -> :ok
64+
{:error, {:already_started, _pid}} -> :ok
65+
end
66+
end
67+
68+
defp print_hash(workflow_id, opts) do
69+
case Workflows.get_workflow(workflow_id) do
70+
nil ->
71+
Mix.raise("Workflow #{workflow_id} not found")
72+
73+
workflow ->
74+
if Keyword.get(opts, :hash, true) do
75+
WorkflowVersions.generate_hash(workflow)
76+
else
77+
WorkflowVersions.canonical_form(workflow)
78+
end
79+
|> IO.puts()
80+
end
81+
end
82+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule Mix.Tasks.Lightning.GenWorkflowHashTest do
2+
use Lightning.DataCase
3+
4+
import ExUnit.CaptureIO
5+
6+
alias Lightning.WorkflowVersions
7+
alias Mix.Tasks.Lightning.GenWorkflowHash
8+
9+
defp run(args) do
10+
capture_io(fn -> GenWorkflowHash.run(args) end) |> String.trim()
11+
end
12+
13+
describe "run/1" do
14+
test "prints the 12-char hash for an existing workflow" do
15+
workflow = build_workflow()
16+
17+
output = run([workflow.id])
18+
19+
assert output == WorkflowVersions.generate_hash(workflow)
20+
assert String.match?(output, ~r/^[a-f0-9]{12}$/)
21+
end
22+
23+
test "with --no-hash prints the canonical pre-hash string" do
24+
workflow = build_workflow()
25+
26+
output = run([workflow.id, "--no-hash"])
27+
28+
assert output == WorkflowVersions.canonical_form(workflow)
29+
# The canonical form is the un-digested input, so it is not a bare hash.
30+
refute String.match?(output, ~r/^[a-f0-9]{12}$/)
31+
end
32+
33+
test "raises when the workflow does not exist" do
34+
id = Ecto.UUID.generate()
35+
36+
assert_raise Mix.Error, "Workflow #{id} not found", fn ->
37+
run([id])
38+
end
39+
end
40+
end
41+
42+
defp build_workflow do
43+
workflow = insert(:workflow, name: "Hashable Workflow")
44+
trigger = insert(:trigger, workflow: workflow, type: :webhook)
45+
job = insert(:job, workflow: workflow, name: "Process")
46+
47+
insert(:edge,
48+
workflow: workflow,
49+
source_trigger: trigger,
50+
target_job: job,
51+
condition_type: :always
52+
)
53+
54+
Repo.preload(workflow, [:triggers, :jobs, :edges])
55+
end
56+
end

0 commit comments

Comments
 (0)