diff --git a/plumber/ppl/lib/ppl/definition_reviser/job_matrix_validator.ex b/plumber/ppl/lib/ppl/definition_reviser/job_matrix_validator.ex index bf0a3b5e4..2ffabaed0 100644 --- a/plumber/ppl/lib/ppl/definition_reviser/job_matrix_validator.ex +++ b/plumber/ppl/lib/ppl/definition_reviser/job_matrix_validator.ex @@ -2,14 +2,21 @@ defmodule Ppl.DefinitionReviser.JobMatrixValidator do @moduledoc """ This module serves to validate that all job matrix values are provided as a list of strings either explicitly or after evaluation by SPC command line tool. + + It also validates that there are no duplicate environment variable names in the job matrix. + It also validates that the total product size of the matrix (product of number of values of each environment variable) is not too large. """ alias Util.ToTuple + @max_size 100 + def validate(definition) do with {:ok, definition} <- do_validate_job_matrix_values(definition, "blocks"), {:ok, definition} <- do_validate_job_matrix_values(definition, "after_pipeline") do ToTuple.ok(definition) + else + {:error, _} = error -> error end end @@ -43,9 +50,29 @@ defmodule Ppl.DefinitionReviser.JobMatrixValidator do jobs = get_in(block, ["build", "jobs"]) |> List.wrap() block_name = get_in(block, ["name"]) - case Enum.find_value(jobs, &validate_job_matrices(block_name, &1)) do - nil -> {:ok, [block] ++ block_acc} - {:error, error} -> {:error, error} + # Calculate total matrix size across all jobs in the block + total_result = + Enum.reduce_while(jobs, {:ok, 0}, fn job, {:ok, total_size} -> + case validate_job_matrices(block_name, job) do + nil -> {:cont, {:ok, total_size}} + {:ok, matrix_size} -> {:cont, {:ok, total_size + matrix_size}} + {:error, _} = error -> {:halt, error} + end + end) + + case total_result do + {:ok, total_size} -> + if total_size > @max_size do + {:error, + {:malformed, + "Total matrix size exceeds maximum allowed size (#{@max_size}) in block '#{block_name}'. " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + else + {:ok, [block] ++ block_acc} + end + + {:error, _} = error -> + error end end @@ -56,10 +83,39 @@ defmodule Ppl.DefinitionReviser.JobMatrixValidator do job_name = Map.get(job, "name") if Map.has_key?(job, "matrix") and is_list(matrix_values) do - Enum.find_value(matrix_values, &check_matrix_values(block_name, job_name, &1)) + case validate_job_matrix(block_name, job_name, matrix_values, job) do + {:ok, matrix_size} -> {:ok, matrix_size} + {:error, _} = error -> error + nil -> nil + end end end + defp validate_job_matrix(block_name, job_name, matrix_values, job) do + with nil <- check_for_duplicate_env_vars(block_name, job_name, matrix_values), + nil <- Enum.find_value(matrix_values, &check_matrix_values(block_name, job_name, &1)), + {:ok, matrix_size} <- check_matrix_product_size(block_name, job_name, matrix_values, job) do + {:ok, matrix_size} + else + {:error, _} = error -> error + end + end + + defp check_for_duplicate_env_vars(block_name, job_name, matrix_values) do + env_var_names_count = + Enum.reduce(matrix_values, %{}, fn matrix_entry, acc -> + env_var = Map.get(matrix_entry, "env_var") + Map.update(acc, env_var, 1, &(&1 + 1)) + end) + + Enum.find_value(env_var_names_count, fn {env_var_name, count} -> + if count > 1 do + {:error, + {:malformed, duplicate_env_var_error_message(block_name, job_name, env_var_name)}} + end + end) + end + defp check_matrix_values(block_name, job_name, matrix_entry) do env_var = get_in(matrix_entry, ["env_var"]) values = get_in(matrix_entry, ["values"]) @@ -72,4 +128,29 @@ defmodule Ppl.DefinitionReviser.JobMatrixValidator do defp error_mesasge(block_name, job_name, env_var) do "Matrix values for env_var '#{env_var}' (block '#{block_name}', job '#{job_name}' must be a non-empty list of strings." end + + defp duplicate_env_var_error_message(block_name, job_name, env_var_name) do + "Duplicate environment variable(s): '#{env_var_name}' in job matrix (block '#{block_name}', job '#{job_name}')." + end + + def check_matrix_product_size(block_name, job_name, matrix_values, _job) do + matrix_size = + Enum.reduce(matrix_values, 1, fn matrix_entry, acc -> + values = get_in(matrix_entry, ["values"]) + if is_list(values), do: min(acc * length(values), @max_size + 1), else: acc + end) + + if matrix_size > @max_size do + {:error, + {:malformed, + matrix_product_size_error_message(block_name, job_name, matrix_size, @max_size)}} + else + {:ok, matrix_size} + end + end + + defp matrix_product_size_error_message(block_name, job_name, size, max_size) do + "Matrix product size exceeds maximum allowed size (#{max_size}) in job matrix (block '#{block_name}', job '#{job_name}'). " <> + "The matrix product size is calculated as the product of the number of values for each environment variable." + end end diff --git a/plumber/ppl/test/definition_reviser/job_matrix_validator_test.exs b/plumber/ppl/test/definition_reviser/job_matrix_validator_test.exs index ce00ac104..a821d8396 100644 --- a/plumber/ppl/test/definition_reviser/job_matrix_validator_test.exs +++ b/plumber/ppl/test/definition_reviser/job_matrix_validator_test.exs @@ -7,207 +7,554 @@ defmodule Ppl.DefinitionReviser.JobMatrixValidator.Test do :ok end - test "jobs with proper matrix values pass validation" do - pipeline = %{ - "blocks" => [ - %{ - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => ["MAR", "MAZ"]}] - } - ] - } - } - ] - } - - assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} - end + describe "validate job matrix values" do + test "jobs with proper matrix values pass validation" do + pipeline = %{ + "blocks" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => ["MAR", "MAZ"]}] + } + ] + } + } + ] + } - test "jobs with empty array of matrix values fail validation" do - pipeline = %{ - "blocks" => [ - %{ - "name" => "Block 1", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "name" => "Block 2", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => []}] - } - ] - } - } - ] - } - - assert JobMatrixValidator.validate(pipeline) == - {:error, {:malformed, "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} - end + assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} + end - test "jobs with empty string of matrix values fail validation" do - pipeline = %{ - "blocks" => [ - %{ - "name" => "Block 1", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "name" => "Block 2", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => ""}] - } - ] - } - } - ] - } - - assert JobMatrixValidator.validate(pipeline) == - {:error, {:malformed, "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} - end + test "jobs with empty array of matrix values fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "name" => "Block 2", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => []}] + } + ] + } + } + ] + } - test "jobs with null matrix values fail validation" do - pipeline = %{ - "blocks" => [ - %{ - "name" => "Block 1", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "name" => "Block 2", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => nil}] - } - ] - } - } - ] - } - - assert JobMatrixValidator.validate(pipeline) == - {:error, {:malformed, "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + end + + test "jobs with empty string of matrix values fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "name" => "Block 2", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => ""}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + end + + test "jobs with null matrix values fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "name" => "Block 2", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => nil}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + end + + test "jobs with matrix values as string fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "name" => "Block 2", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => "FOOBAR"}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + end + + test "jobs with matrix values as string in after_pipeline job fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "name" => "Block 2", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1" + } + ] + } + } + ], + "after_pipeline" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => "FOOBAR"}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix values for env_var 'MOO' (block 'after_pipeline', job 'Job 1' must be a non-empty list of strings."}} + end end - test "jobs with matrix values as string fail validation" do - pipeline = %{ - "blocks" => [ - %{ - "name" => "Block 1", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "name" => "Block 2", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => "FOOBAR"}] - } - ] - } - } - ] - } - - assert JobMatrixValidator.validate(pipeline) == - {:error, {:malformed, "Matrix values for env_var 'MOO' (block 'Block 2', job 'Job 1' must be a non-empty list of strings."}} + describe "validate job matrix environment variables duplicates" do + test "jobs with duplicate env_var names in matrix fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [ + %{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}, + %{"env_var" => "FOO", "values" => ["QUX", "QUUX"]} + ] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Duplicate environment variable(s): 'FOO' in job matrix (block 'Block 1', job 'Job 1')."}} + end + + test "jobs with duplicate env_var names in after_pipeline matrix fail validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{"name" => "Job 1"} + ] + } + } + ], + "after_pipeline" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [ + %{"env_var" => "MOO", "values" => ["VAL1", "VAL2"]}, + %{"env_var" => "MOO", "values" => ["VAL3", "VAL4"]} + ] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Duplicate environment variable(s): 'MOO' in job matrix (block 'after_pipeline', job 'Job 1')."}} + end + + test "jobs with multiple unique env_var names in matrix pass validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [ + %{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}, + %{"env_var" => "MOO", "values" => ["MAR", "MAZ"]} + ] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} + end + + test "jobs with proper matrix values in different blocks pass validation" do + pipeline = %{ + "blocks" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] + }, + %{"name" => "Job 2"} + ] + } + }, + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "MOO", "values" => ["MAR", "MAZ"]}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} + end end - test "jobs with matrix values as string in after_pipeline job fail validation" do - pipeline = %{ - "blocks" => [ - %{ - "name" => "Block 1", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "FOO", "values" => ["BAR", "BAZ"]}] - }, - %{"name" => "Job 2"} - ] - } - }, - %{ - "name" => "Block 2", - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1" - } - ] - } - } - ], - "after_pipeline" => [%{ - "build" => %{ - "jobs" => [ - %{ - "name" => "Job 1", - "matrix" => [%{"env_var" => "MOO", "values" => "FOOBAR"}] - } - ] - } - }] - } - - assert JobMatrixValidator.validate(pipeline) == - {:error, {:malformed, "Matrix values for env_var 'MOO' (block 'after_pipeline', job 'Job 1' must be a non-empty list of strings."}} + describe "validate job matrix size limit" do + test "job with matrix size exceeding limit fails validation" do + # Create a matrix with values that will exceed the @max_size (100) + # We'll create a matrix with 101 values (101 > 100) + values = Enum.map(1..101, &"value_#{&1}") + + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => values}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix product size exceeds maximum allowed size (100) in job matrix (block 'Block 1', job 'Job 1'). " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + end + + test "job with matrix product size exceeding limit fails validation" do + # Create a matrix with multiple env vars whose product exceeds the @max_size (100) + # We'll use 11 x 10 = 110 > 100 + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [ + %{"env_var" => "FOO", "values" => Enum.map(1..11, &"foo_#{&1}")}, + %{"env_var" => "BAR", "values" => Enum.map(1..10, &"bar_#{&1}")} + ] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix product size exceeds maximum allowed size (100) in job matrix (block 'Block 1', job 'Job 1'). " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + end + + test "total matrix size across multiple jobs in a block exceeding limit fails validation" do + # Create multiple jobs in a block where the total matrix size exceeds the limit + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => Enum.map(1..60, &"foo_#{&1}")}] + }, + %{ + "name" => "Job 2", + "matrix" => [%{"env_var" => "BAR", "values" => Enum.map(1..50, &"bar_#{&1}")}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Total matrix size exceeds maximum allowed size (100) in block 'Block 1'. " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + end + + test "jobs with matrix size within limit pass validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [ + %{"env_var" => "FOO", "values" => Enum.map(1..5, &"foo_#{&1}")}, + %{"env_var" => "BAR", "values" => Enum.map(1..10, &"bar_#{&1}")} + ] + }, + %{ + "name" => "Job 2", + "matrix" => [%{"env_var" => "BAZ", "values" => Enum.map(1..8, &"baz_#{&1}")}] + } + ] + } + } + ] + } + + # Total size: (5*10) + 8 = 58, which is less than 100 + assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} + end + + test "jobs with matrix size at the limit pass validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => Enum.map(1..50, &"foo_#{&1}")}] + }, + %{ + "name" => "Job 2", + "matrix" => [%{"env_var" => "BAR", "values" => Enum.map(1..50, &"bar_#{&1}")}] + } + ] + } + } + ] + } + + # Total size: 50 + 50 = 100, which is exactly at the limit + assert JobMatrixValidator.validate(pipeline) == {:ok, pipeline} + end + + test "after_pipeline jobs with matrix size exceeding limit fails validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => Enum.map(1..10, &"foo_#{&1}")}] + } + ] + } + } + ], + "after_pipeline" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "After Job 1", + "matrix" => [%{"env_var" => "BAR", "values" => Enum.map(1..101, &"bar_#{&1}")}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Matrix product size exceeds maximum allowed size (100) in job matrix (block 'after_pipeline', job 'After Job 1'). " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + end + + test "total matrix size across multiple jobs in after_pipeline exceeding limit fails validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "matrix" => [%{"env_var" => "FOO", "values" => Enum.map(1..10, &"foo_#{&1}")}] + } + ] + } + } + ], + "after_pipeline" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "After Job 1", + "matrix" => [%{"env_var" => "BAR", "values" => Enum.map(1..60, &"bar_#{&1}")}] + }, + %{ + "name" => "After Job 2", + "matrix" => [%{"env_var" => "BAZ", "values" => Enum.map(1..50, &"baz_#{&1}")}] + } + ] + } + } + ] + } + + assert JobMatrixValidator.validate(pipeline) == + {:error, + {:malformed, + "Total matrix size exceeds maximum allowed size (100) in block 'after_pipeline'. " <> + "The matrix product size is calculated as the product of the number of values for each environment variable."}} + end end end diff --git a/plumber/spec/priv/v1.0.yml b/plumber/spec/priv/v1.0.yml index 442f9fb19..1064c1ee1 100644 --- a/plumber/spec/priv/v1.0.yml +++ b/plumber/spec/priv/v1.0.yml @@ -211,6 +211,7 @@ definitions: oneOf: - type: integer minimum: 1 + maximum: 50 - type: string matrix: type: array @@ -262,6 +263,7 @@ definitions: required: [name] env_vars: type: array + maxItems: 10000 items: type: object $ref: "#/definitions/env_var"