Skip to content
Open
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
59 changes: 55 additions & 4 deletions src/openjd/cli/_run/_local_session/_actions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

import inspect
from enum import Enum
from typing import Any, Optional

from openjd.model import Step, TaskParameterSet
from openjd.model.v2023_09 import Environment
from openjd.sessions import Session

# Version-skew guard: the step_name keyword was added to
# Session.enter_environment alongside RFC 0007 Step.Name support. Detect it
# once so this CLI keeps working against older openjd-sessions releases that
# don't accept the keyword (every step-environment enter carries a step name,
# so a value-presence check alone isn't enough here).
_ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME: bool = (
"step_name" in inspect.signature(Session.enter_environment).parameters
)


class EnvironmentType(str, Enum):
"""
Expand Down Expand Up @@ -47,7 +58,11 @@ def __init__(self, session: Session, step: Step, parameters: TaskParameterSet):

def run(self):
self._session.run_task(
step_script=self._step.script, task_parameter_values=self._parameters
step_script=self._step.script,
task_parameter_values=self._parameters,
# RFC 0008: the step name feeds the WrappedStep.Name template
# variable inside an active onWrapTaskRun hook.
step_name=self._step.name,
)

def __str__(self):
Expand All @@ -58,14 +73,50 @@ def __str__(self):
class EnterEnvironmentAction(SessionAction):
_environment: Environment
_id: str

def __init__(self, session: Session, environment: Environment, env_id: str):
_extra_let_bindings: Optional[list[str]]
_step_name: Optional[str]

def __init__(
self,
session: Session,
environment: Environment,
env_id: str,
extra_let_bindings: Optional[list[str]] = None,
step_name: Optional[str] = None,
):
super(EnterEnvironmentAction, self).__init__(session)
self._environment = environment
self._id = env_id
# RFC 0007: a step's environments are entered with the step-level
# `let` bindings so their variables/actions can reference them.
self._extra_let_bindings = extra_let_bindings
# RFC 0007 §7.3.1 (EXPR): the owning step's name seeds Step.Name for
# a step environment's `let` bindings, variables, and actions. Only
# step-environment enters carry a step name; job/external enters
# leave it None.
self._step_name = step_name

def run(self):
self._session.enter_environment(environment=self._environment, identifier=self._id)
# Backwards compatibility: only forward `extra_let_bindings` when the
# step actually defines `let` bindings (RFC 0007). Older
# openjd-sessions releases don't accept the keyword, so omitting it
# by default keeps this CLI working with any sessions version for
# every template that doesn't use step-level lets — the reasonable
# default is simply "no extra bindings". `step_name` follows the same
# pattern, but since every step-environment enter has a step name, it
# is additionally gated on the installed openjd-sessions accepting
# the keyword; without it, Step.Name simply stays undefined (the
# pre-RFC 0007 behavior).
optional_kwargs: dict[str, Any] = {}
if self._extra_let_bindings:
optional_kwargs["extra_let_bindings"] = self._extra_let_bindings
if self._step_name is not None and _ENTER_ENVIRONMENT_ACCEPTS_STEP_NAME:
optional_kwargs["step_name"] = self._step_name
self._session.enter_environment(
environment=self._environment,
identifier=self._id,
**optional_kwargs,
)

def __str__(self):
return f"Enter Environment '{self._environment.name}'"
Expand Down
66 changes: 61 additions & 5 deletions src/openjd/cli/_run/_local_session/_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ def __init__(
self._openjd_session = Session(
session_id=self.session_id,
job_parameter_values=job_parameter_values,
# RFC 0007 §7.3.1 (EXPR): seeds the Job.Name template variable.
job_name=str(job.name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent backwards-compatibility handling for new openjd-sessions keywords.

This PR carefully guards extra_let_bindings — the comment at enter_environment (in _actions.py) states that older openjd-sessions releases do not accept that keyword, so it is only forwarded when step-level let bindings exist. But job_name here (and step_name in _actions.py:55, passed to run_task) is passed unconditionally, and pyproject.toml still pins openjd-sessions >= 0.10.7, < 0.11 (unchanged by this PR).

If the RFC-0007/0008 keywords (job_name, step_name) are not present in the minimum supported sessions version — which is exactly the premise behind guarding extra_let_bindings — then Session(...) and run_task(...) will raise TypeError: unexpected keyword argument for any user on that version, breaking every openjd run invocation regardless of whether the template uses these extensions.

Either raise the openjd-sessions lower bound to the first release that accepts job_name/step_name, or guard these the same way extra_let_bindings is guarded. Whichever is correct, the three new keywords should be handled consistently.

path_mapping_rules=self._path_mapping_rules,
callback=self._action_callback,
retain_working_dir=retain_working_dir,
Expand Down Expand Up @@ -197,7 +199,14 @@ def _sigint_handler(self, signum: int, frame: Optional[FrameType]) -> None:
LOG.info("Interruption signal recieved.")
self.cancel()

def run_environment_enters(self, environments: Optional[list[Any]], type: EnvironmentType):
def run_environment_enters(
self,
environments: Optional[list[Any]],
type: EnvironmentType,
*,
extra_let_bindings: Optional[list[str]] = None,
step_name: Optional[str] = None,
):
"""Enter one or more environments in the session."""
if environments is None:
return
Expand All @@ -211,10 +220,43 @@ def run_environment_enters(self, environments: Optional[list[Any]], type: Enviro
env_id = f"{type.name} - {env.name}"
self._action_ended.clear()
self._current_action = EnterEnvironmentAction(
session=self._openjd_session, environment=env, env_id=env_id
session=self._openjd_session,
environment=env,
env_id=env_id,
# RFC 0007: a step's environments see the step-level `let`
# bindings.
extra_let_bindings=extra_let_bindings,
# RFC 0007 §7.3.1 (EXPR): a step's environments see Step.Name.
# Only step-environment enters carry a step name.
step_name=step_name,
)
self._environments_entered.append((type, env_id))
self._current_action.run()
try:
self._current_action.run()
except (RuntimeError, ValueError) as exc:
# Session.enter_environment raises (rather than reporting
# through the action-status callback) when it rejects the
# environment up front — e.g. the RFC 0008 "at most one wrap
# environment" RuntimeError, or a ValueError from the extra
# `let` bindings — but it can also raise *after* registering
# the environment (e.g. an environment `variables` expression
# that fails to evaluate). Only when the session did NOT
# register the environment may we drop it from our entered
# list (cleanup must not try to exit it). If the session did
# register it, it must stay in our list so cleanup exits it —
# popping it here would skip its onExit and desynchronize us
# from the session's LIFO exit ordering check, masking the
# original error with "Must exit Environment X first".
if env_id not in self._openjd_session.environments_entered:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded self._openjd_session.environments_entered in the new error path may itself raise AttributeError on older openjd-sessions.

This block is otherwise carefully defensive about version skew — step_name acceptance is probed with inspect.signature (_actions.py), and step.let is read via getattr(step, "let", None). But environments_entered is read bare on the Session. pyproject.toml still pins openjd-sessions >= 0.10.7, < 0.11 (unchanged by this PR). If this public property was introduced after 0.10.7, the access raises AttributeError here.

The consequence is worse than usual because this is inside the except (RuntimeError, ValueError) handler: an AttributeError here is not caught, so it escapes and masks the original enter failure with an unrelated traceback (and skips setting self.failed / raising LocalSessionFailed).

Please confirm environments_entered is present in the minimum pinned openjd-sessions (0.10.7). If it is guaranteed, this is fine; if not, guard it the same way as the neighboring session APIs (e.g. getattr(self._openjd_session, "environments_entered", ())) and raise the version floor.

self._environments_entered.pop()
LOG.info(
msg=f"Open Job Description CLI: ERROR entering environment '{env.name}': {exc}",
extra={"session_id": self.session_id},
)
self.failed = True
self._failed_action = self._current_action
self._current_action = None
raise LocalSessionFailed(self._failed_action) from exc
self._action_ended.wait()
if self.failed:
self._failed_action = self._current_action
Expand Down Expand Up @@ -348,8 +390,22 @@ def run_step(
if task_parameters is None:
task_parameters = StepParameterSpaceIterator(space=step.parameterSpace)

# Enter all the step environments
self.run_environment_enters(step.stepEnvironments, EnvironmentType.STEP)
# Enter all the step environments. When the step defines step-level
# `let` bindings (RFC 0007), its environments are entered with them so
# their variables and actions can reference them.
# getattr guard: requires an openjd-model with Step.let on the
# instantiated Job (openjd-model PR #318+); collapse to plain
# `step.let` once the version pin floor guarantees it.
step_let_bindings = getattr(step, "let", None)
self.run_environment_enters(
step.stepEnvironments,
EnvironmentType.STEP,
extra_let_bindings=step_let_bindings or None,
# RFC 0007 §7.3.1 (EXPR): Step.Name is available to a step's
# environments (openjd-rs threads the step's resolved symbol
# table into enter_environment; this is the CLI counterpart).
step_name=step.name,
)

try:
# Run the tasks
Expand Down
23 changes: 20 additions & 3 deletions src/openjd/cli/_run/_run_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def _run_local_session(
Creates a Session object and listens for log messages to synchronously end the session.
"""

error_message = "Session ended with errors; see Task logs for details"
try:
start_seconds = time.perf_counter()

Expand Down Expand Up @@ -367,6 +368,14 @@ def _run_local_session(
except LocalSessionFailed:
duration = time.perf_counter() - start_seconds
session = None
except (RuntimeError, ValueError) as exc:
# Exceptions raised by openjd.sessions from within session actions
# (e.g. the RFC 0008 "at most one wrap environment" RuntimeError)
# rather than reported through the action-status callback. Report
# them as a clean error result instead of a raw traceback.
duration = time.perf_counter() - start_seconds
session = None
error_message = f"Session ended with errors: {exc}"

preserved_message: str = ""
if retain_working_dir and session is not None:
Expand All @@ -377,7 +386,7 @@ def _run_local_session(
if session is None or session.failed:
return OpenJDRunResult(
status="error",
message="Session ended with errors; see Task logs for details" + preserved_message,
message=error_message + preserved_message,
job_name=job.name,
step_name=step_name,
duration=duration,
Expand Down Expand Up @@ -547,9 +556,17 @@ def do_run(args: Namespace) -> OpenJDCliResult:
return OpenJDCliResult(status="error", message=str(rte))

# Create a RevisionExtensions object with the default specification version and enabled extensions
# We use the default v2023_09 since that's what we're currently supporting
# We use the default v2023_09 since that's what we're currently supporting.
# Extension behaviors activate only when declared by the templates being
# run (the job template and any external environment templates) — the
# CLI's --extensions list is what the CLI *accepts*, not what is enabled.
declared_extensions: list[str] = list(the_job.extensions or [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct .extensions attribute access may raise AttributeError on older openjd-model.

the_job.extensions (line 554) and env_template.extensions (line 556) are new attribute accesses. pyproject.toml pins openjd-model >= 0.9, < 0.12 (unchanged by this PR). If the extensions attribute was introduced in a later model version than 0.9, these accesses raise AttributeError on the lower end of the supported range, breaking openjd run entirely.

Note the asymmetry with _session_manager.py:369, which defensively uses getattr(step, "let", None) for a similarly new model attribute. Consider using the same defensive pattern here (e.g. getattr(the_job, "extensions", None) or []), or raise the openjd-model floor to the first version that guarantees extensions on Job and EnvironmentTemplate.

for env_template in environments or []:
for ext_name in env_template.extensions or []:
if ext_name not in declared_extensions:
declared_extensions.append(ext_name)
revision_extensions = RevisionExtensions(
spec_rev=the_job.revision, supported_extensions=extensions
spec_rev=the_job.revision, supported_extensions=declared_extensions
)

return _run_local_session(
Expand Down
27 changes: 27 additions & 0 deletions test/openjd/cli/templates/step_name_env_job.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# RFC 0007 §7.3.1 (EXPR): Step.Name is available to a step's environments.
# The step-level `let` binding references Step.Name, and the step's
# environment is entered with the binding so its onEnter action can echo it.
specificationVersion: jobtemplate-2023-09
extensions:
- EXPR
name: StepNameEnvJob
steps:
- name: EchoStepName
let:
- bound_name = Step.Name
stepEnvironments:
- name: NameEcho
script:
actions:
onEnter:
command: python
args:
- -c
- print('EnvSaw={{ bound_name }}')
script:
actions:
onRun:
command: python
args:
- -c
- print('TaskRan')
Loading
Loading