diff --git a/Cargo.lock b/Cargo.lock index 5eb3dc68..00aeaa21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,9 +696,9 @@ dependencies = [ [[package]] name = "openjd-model" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdfffa82b1dbb94865d10c935cd547b4319932280f37b87db475dca961b7ef91" +checksum = "ca2537f12cbe572cb0f0a36bca44c858e8e943a30603c583c959aa8453a2c62d" dependencies = [ "indexmap", "openjd-expr", @@ -728,9 +728,9 @@ dependencies = [ [[package]] name = "openjd-sessions" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877446585bd38cff6a40c68293f62b2147e5fa4f0997a83a39c5d2af38585449" +checksum = "9dc782dfcc53e6850422c30526e94fb80657650c7123152f05166b9873e20e49" dependencies = [ "bitflags", "futures-util", diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 3b23a560..d2851ffa 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -2535,8 +2535,8 @@ limitations under the License. ** libc; version 0.2.189 -- https://crates.io/crates/libc ** manyhow-macros; version 0.11.4 -- https://crates.io/crates/manyhow-macros ** openjd-expr; version 0.7.0 -- https://crates.io/crates/openjd-expr -** openjd-model; version 0.7.0 -- https://crates.io/crates/openjd-model -** openjd-sessions; version 0.5.7 -- https://crates.io/crates/openjd-sessions +** openjd-model; version 0.7.1 -- https://crates.io/crates/openjd-model +** openjd-sessions; version 0.5.8 -- https://crates.io/crates/openjd-sessions ** pin-project-lite; version 0.2.17 -- https://crates.io/crates/pin-project-lite ** portable-atomic; version 1.15.0 -- https://crates.io/crates/portable-atomic ** proc-macro2; version 1.0.107 -- https://crates.io/crates/proc-macro2 diff --git a/rust-bindings/Cargo.toml b/rust-bindings/Cargo.toml index 235bcc7c..ab2b966b 100644 --- a/rust-bindings/Cargo.toml +++ b/rust-bindings/Cargo.toml @@ -13,8 +13,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] openjd-expr = "0.7.0" -openjd-model = "0.7.0" -openjd-sessions = "0.5.7" +openjd-model = "0.7.1" +openjd-sessions = "0.5.8" tokio = { version = "1", features = ["rt-multi-thread"] } uuid = { version = "1", features = ["v4"] } serde_json = "1" diff --git a/test/openjd/model_v0/v2023_09/test_job_template.py b/test/openjd/model_v0/v2023_09/test_job_template.py index 8a96a89c..3f9d947a 100644 --- a/test/openjd/model_v0/v2023_09/test_job_template.py +++ b/test/openjd/model_v0/v2023_09/test_job_template.py @@ -180,6 +180,26 @@ class TestJobTemplate: }, id="job and step env names all differ", ), + pytest.param( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Foo", + "steps": [ + { + "name": "StepOne", + "script": STEP_SCRIPT, + "stepEnvironments": [{"name": "StepEnv", "script": ENV_SCRIPT}], + }, + { + "name": "StepTwo", + "script": STEP_SCRIPT, + "stepEnvironments": [{"name": "StepEnv", "script": ENV_SCRIPT}], + }, + ], + "jobEnvironments": [{"name": "JobEnv", "script": ENV_SCRIPT}], + }, + id="step env name reused across steps", + ), ), ) def test_parse_success(self, data: dict[str, Any]) -> None: diff --git a/test/openjd/model_v1/test_parse.py b/test/openjd/model_v1/test_parse.py index 35267aef..feefc1c2 100644 --- a/test/openjd/model_v1/test_parse.py +++ b/test/openjd/model_v1/test_parse.py @@ -7,6 +7,7 @@ import pytest from openjd.model._v1 import ( + CallerLimits, decode_environment_template, decode_environment_template_str, decode_job_template, @@ -405,3 +406,101 @@ def test_json_explicit(self) -> None: def test_invalid_yaml_raises(self) -> None: with pytest.raises(DecodeValidationError): decode_environment_template_str(": not a mapping") + + +class TestStepEnvironmentNameScope(object): + """A Step Environment's ``name`` is scoped to the Step that defines it (Template + Schemas §3 StepTemplate, §4 Environment): unique within that Step's list, and + distinct from every Job Environment. Different Steps may reuse a name. + + openjd-model 0.7.1 (openjd-rs#381) relaxed an over-strict check that held every + environment name in the template in one set, so the second Step to declare + ``StepEnv`` was rejected. The v0 path always accepted this; this is the v1 path, + which had no coverage. + + The error-text assertions below pin the v1 wording as it stands. It differs from + v0 on purpose-of-record, not by design: v0 reports the step-vs-job rule as + ``Name X must differ from the names of Environments defined at the root of the + template.`` at ``step[i] -> stepEnvironments[j] -> name``, while v1 reports both + the per-Step and the step-vs-job rule as ``duplicate environment name: 'X'`` at + ``steps[i] -> stepEnvironments[j]``. Aligning the two is an openjd-rs concern; + these assertions only guard against the relaxation dropping a rule. + """ + + @staticmethod + def _environment(name: str) -> dict[str, Any]: + return { + "name": name, + "script": {"actions": {"onEnter": {"command": "echo", "args": [name]}}}, + } + + @classmethod + def _step(cls, name: str, environment_names: list[str]) -> dict[str, Any]: + return { + "name": name, + "stepEnvironments": [cls._environment(n) for n in environment_names], + "script": {"actions": {"onRun": {"command": "echo", "args": [name]}}}, + } + + @classmethod + def _template(cls, steps: list[dict[str, Any]]) -> dict[str, Any]: + return { + "specificationVersion": "jobtemplate-2023-09", + "name": "T", + "jobEnvironments": [cls._environment("JobEnv")], + "steps": steps, + } + + def test_same_name_across_steps_is_accepted(self) -> None: + """Four Steps each declare ``StepEnv``. Only one Step's environments are ever + active in a Session, so these names never collide.""" + template = self._template([self._step(f"Step{i}", ["StepEnv"]) for i in range(4)]) + job_template = decode_job_template(template=template, supported_extensions=[]) + names = [[e.name for e in (s.step_environments or [])] for s in job_template.steps] + assert names == [["StepEnv"]] * 4 + + def test_duplicate_within_one_step_is_rejected(self) -> None: + """Control for §3 rule 1: the per-Step uniqueness check must survive the relaxation.""" + template = self._template( + [self._step("Step0", ["StepEnv"]), self._step("Step1", ["StepEnv", "StepEnv"])] + ) + with pytest.raises(ModelValidationError) as excinfo: + decode_job_template(template=template, supported_extensions=[]) + message = str(excinfo.value) + assert "steps[1] -> stepEnvironments[1]" in message + assert "duplicate environment name: 'StepEnv'" in message + + def test_step_env_named_like_job_env_is_rejected(self) -> None: + """Control for §3 rule 2: a Step Environment may not reuse a Job Environment name.""" + template = self._template( + [self._step("Step0", ["StepEnv"]), self._step("Step1", ["JobEnv"])] + ) + with pytest.raises(ModelValidationError) as excinfo: + decode_job_template(template=template, supported_extensions=[]) + message = str(excinfo.value) + assert "steps[1] -> stepEnvironments[0]" in message + assert "duplicate environment name: 'JobEnv'" in message + + def test_duplicate_job_env_names_is_rejected(self) -> None: + """Control for §4 uniqueness within ``jobEnvironments``. The relaxation split one + template-wide set into a job set plus a per-Step set; this pins the job set.""" + template = self._template([self._step("Step0", ["StepEnv"])]) + template["jobEnvironments"] = [self._environment("JobEnv"), self._environment("JobEnv")] + with pytest.raises(ModelValidationError) as excinfo: + decode_job_template(template=template, supported_extensions=[]) + message = str(excinfo.value) + assert "jobEnvironments[1]" in message + assert "duplicate environment name: 'JobEnv'" in message + + def test_max_env_count_counts_repeated_names_separately(self) -> None: + """``max_env_count`` bounds the number of environments, not the number of distinct + names. Four Steps each declaring ``StepEnv`` plus ``JobEnv`` is 5 environments + under 2 names, so a limit of 4 must reject; counting distinct names would not.""" + template = self._template([self._step(f"Step{i}", ["StepEnv"]) for i in range(4)]) + with pytest.raises(ModelValidationError) as excinfo: + decode_job_template( + template=template, + supported_extensions=[], + caller_limits=CallerLimits(max_env_count=4), + ) + assert "total environments (5) exceeds caller limit of 4" in str(excinfo.value)