-
Notifications
You must be signed in to change notification settings - Fork 29
RFC: Environment Wrap Actions #130
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: mainline
Are you sure you want to change the base?
Changes from all commits
0dec2b8
1d78808
3272c94
a1ba8a4
befd584
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| # WRAP_ACTIONS Conformance Tests | ||
|
|
||
| Conformance tests for the `WRAP_ACTIONS` extension defined in | ||
| [RFC 0008 — Environment Wrap Actions](../../../rfcs/0008-environment-wrap-actions.md). | ||
|
|
||
| ## What this extension does | ||
|
|
||
| `WRAP_ACTIONS` adds three new fields to `<EnvironmentActions>`: | ||
|
|
||
| ```yaml | ||
| <EnvironmentActions> ::= the object: | ||
| onEnter: <Action> # optional | ||
| onWrapEnter: <Action> # NEW — optional | ||
| onWrapTaskRun: <Action> # NEW — optional | ||
| onWrapExit: <Action> # NEW — optional | ||
| onExit: <Action> # optional | ||
| ``` | ||
|
|
||
| When an active environment defines the wrap hooks, the runtime runs each | ||
| hook *instead of* the corresponding lifecycle action of every inner | ||
| environment and task. Each hook receives the wrapped action's fields via | ||
| the `WrappedAction.*` template variables: | ||
|
|
||
| | Variable | Type | Description | | ||
| |-------------------------------|----------------|---------------------------------------------------------------------| | ||
| | `WrappedAction.Command` | `string` | The `command` from the wrapped action | | ||
| | `WrappedAction.Args` | `list[string]` | The `args` from the wrapped action | | ||
| | `WrappedAction.Environment` | `list[string]` | `openjd_env`-defined variables as `["KEY=value", ...]` | | ||
| | `WrappedAction.Timeout` | `int` | Timeout of the wrapped action in seconds, or `0` if none | | ||
| | `Env.Wrapped.Name` | `string` | Name of the inner environment (only in `onWrapEnter`/`onWrapExit`) | | ||
|
|
||
| ## RFC rules these tests verify | ||
|
|
||
| - **All-or-nothing**: defining any wrap hook requires defining all three | ||
| (`onWrapEnter`, `onWrapTaskRun`, `onWrapExit`). | ||
| - **Single wrap layer**: at most one environment in the session stack may | ||
| define wrap hooks. | ||
| - **Variable scope**: `WrappedAction.*` may be referenced only inside the | ||
| three wrap hooks; `Env.Wrapped.*` only inside `onWrapEnter`/`onWrapExit`. | ||
| - **Extension gating**: wrap hooks are only valid when `WRAP_ACTIONS` is | ||
| declared in `extensions:`. | ||
| - **Interception**: the wrap hooks run instead of the corresponding | ||
| lifecycle actions of inner environments and tasks. | ||
| - **Safe forwarding**: with `repr_sh()` from the EXPR extension, arbitrary | ||
| user input (quotes, metacharacters, globs, Unicode) reaches the wrapped | ||
| process verbatim. | ||
|
|
||
| ## How these tests are designed | ||
|
|
||
| The RFC motivates the wrap hooks with Docker/Apptainer use cases, but | ||
| those containers aren't required to *test* the mechanism. These tests use | ||
| trivial wrap scripts — typically `echo`, `sh -c`, or `printf` — that | ||
| observe the injected `WrappedAction.*` variables and write sentinel | ||
| markers to stdout. | ||
|
|
||
| A test passes if: | ||
| - Each wrap hook runs in place of its corresponding lifecycle action | ||
| (verified by sentinels the original action would never emit). | ||
| - The injected `WrappedAction.*` variables carry the exact bytes of the | ||
| wrapped action. | ||
| - The original action does **not** execute. | ||
|
|
||
| ## Layout | ||
|
|
||
| ``` | ||
| WRAP_ACTIONS/ | ||
| ├── README.md (this file) | ||
| ├── env_templates/ # Env template validation tests | ||
| │ ├── 4.2--minimal-wrap-env-template.yaml | ||
| │ ├── 4.2--wrap-only-environment.yaml | ||
| │ ├── 4.2--wrap-with-timeout.yaml | ||
| │ ├── 4.2--empty-actions-no-wrap-no-enter-no-exit.invalid.yaml | ||
| │ ├── 4.3--wrap-only-onwrap-task-run.invalid.yaml | ||
| │ ├── 4.3--wrap-missing-onwrap-exit.invalid.yaml | ||
| │ └── 4.3--wrappedaction-outside-wrap-hook.invalid.yaml | ||
| └── jobs/ # End-to-end execution tests | ||
| ├── wrap-intercepts-simple-echo.test.yaml | ||
| ├── wrap-original-onrun-does-not-run.test.yaml | ||
| ├── wrap-enter-and-exit-three-hooks.test.yaml | ||
| ├── wrap-enter-intercepts-inner-on-enter.test.yaml | ||
| ├── wrap-exit-intercepts-inner-on-exit.test.yaml | ||
| ├── wrap-task-command-injected.test.yaml | ||
| ├── wrap-task-args-preserved.test.yaml | ||
| ├── wrap-task-environment-injected.test.yaml | ||
| ├── wrap-task-action-timeout-injected.test.yaml | ||
| ├── wrap-preserves-shell-metacharacters.test.yaml | ||
| ├── wrap-preserves-nested-quotes.test.yaml | ||
| ├── wrap-preserves-unicode-cjk.test.yaml | ||
| ├── wrap-preserves-empty-arg.test.yaml | ||
| ├── wrap-glob-characters-literal.test.yaml | ||
| ├── wrap-path-traversal-literal.test.yaml | ||
| ├── wrap-outer-env-without-wrap-ignored.test.yaml | ||
| ├── wrap-runs-for-every-task.test.yaml | ||
| ├── wrap-ignores-wrapped-action-vars.test.yaml | ||
| ├── wrap-without-extension-fails.invalid.test.yaml | ||
| ├── wrap-multiple-wrap-envs-rejected.invalid.test.yaml | ||
| └── wrap-partial-hooks-rejected.invalid.test.yaml | ||
| ``` | ||
|
|
||
| All execution tests use POSIX shell commands (`sh`, `echo`, `printf`), | ||
| so they are gated to `runOn: [posix]`. | ||
|
|
||
| ## Test matrix — RFC §"Recommended test cases for implementation" | ||
|
|
||
| The RFC's container-implementation companion lists scenarios that any | ||
| implementation should validate. This matrix maps the security-related | ||
| ones to the job bundles in this directory: | ||
|
|
||
| | # | Scenario | Test file | | ||
| |---|-----------------------------------|-------------------------------------------------| | ||
| | 1 | Nested quoting | `wrap-preserves-nested-quotes.test.yaml` | | ||
| | 2 | Shell metacharacters | `wrap-preserves-shell-metacharacters.test.yaml` | | ||
| | 3 | Path traversal | `wrap-path-traversal-literal.test.yaml` | | ||
| | 4 | Shell globbing | `wrap-glob-characters-literal.test.yaml` | | ||
| | 5 | Unicode paths | `wrap-preserves-unicode-cjk.test.yaml` | | ||
| | 6 | Empty / whitespace-only arguments | `wrap-preserves-empty-arg.test.yaml` | | ||
| | 7 | Newlines in arguments | Rejected by the 2023-09 `ArgString` regex — | | ||
| | | | no dedicated conformance test needed. | | ||
| | 8 | Near-limit command length | Covered by unit tests in the implementation. | | ||
| | | | Conformance runners vary in stdout buffer | | ||
| | | | limits, so this is left to implementation tests | | ||
| | | | rather than conformance. | | ||
|
|
||
| ## Additional semantics tested | ||
|
|
||
| - **Three-hook interception**: each hook runs in place of its | ||
| corresponding lifecycle action | ||
| (`wrap-enter-intercepts-inner-on-enter`, | ||
| `wrap-exit-intercepts-inner-on-exit`, | ||
| `wrap-enter-and-exit-three-hooks`). | ||
| - **Variable injection**: `WrappedAction.Command/Args/Environment/Timeout` | ||
| reach the wrap script with the right values. | ||
| - **Outer-non-wrap-env coexistence**: a non-wrapping environment can | ||
| appear in the stack alongside the single wrapping environment | ||
| (`wrap-outer-env-without-wrap-ignored`). | ||
| - **Per-task repeat**: variables re-inject cleanly per task | ||
| (`wrap-runs-for-every-task`). | ||
| - **Invalid templates**: | ||
| - `wrap-without-extension-fails`: hooks require the extension. | ||
| - `wrap-multiple-wrap-envs-rejected`: at most one wrap env per session. | ||
| - `wrap-partial-hooks-rejected`: all-or-nothing rule (job side). | ||
| - `4.3--wrap-only-onwrap-task-run`: all-or-nothing rule (env side). | ||
| - `4.3--wrap-missing-onwrap-exit`: all-or-nothing rule (env side). | ||
| - `4.3--wrappedaction-outside-wrap-hook`: variable scope rule. | ||
|
|
||
| ## Running the tests | ||
|
|
||
| From the repo root: | ||
|
|
||
| ```bash | ||
| # Run just the WRAP_ACTIONS extension tests | ||
| uv run conformance-tests/run_openjd_cli_tests.py 2023-09/WRAP_ACTIONS | ||
|
|
||
| # Run only the job execution tests | ||
| uv run conformance-tests/run_openjd_cli_tests.py 2023-09/WRAP_ACTIONS/jobs | ||
|
|
||
| # Pattern-match a single scenario | ||
| uv run conformance-tests/run_openjd_cli_tests.py '*wrap*unicode*' | ||
| ``` | ||
|
|
||
| ## Writing your own runner | ||
|
|
||
| See the top-level [conformance README](../../README.md) for the standard | ||
| runner contract. The behavior specific to `WRAP_ACTIONS`: | ||
|
|
||
| 1. When the env template declares `extensions: [WRAP_ACTIONS]`, the | ||
| runner must accept `onWrapEnter`, `onWrapTaskRun`, and `onWrapExit` | ||
| under `environment.script.actions`, and must reject the template if | ||
| any one of the three is defined without all three. | ||
| 2. When an environment with wrap hooks is entered, every subsequent | ||
| inner-environment lifecycle action and task `onRun` must execute the | ||
| corresponding wrap hook in place of the original action, with | ||
| `WrappedAction.*` variables populated from the wrapped action's fields. | ||
| 3. When two or more environments in the session stack define any wrap | ||
| hook, the runner must reject the session before entering any | ||
| environment. | ||
| 4. When wrap-hook fields are used without `WRAP_ACTIONS` in the | ||
| `extensions` list, the runner must reject the template at validation | ||
| time. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # An environment whose `actions` block is empty is invalid: at least one of | ||
| # onEnter, onWrapEnter, onWrapTaskRun, onWrapExit, or onExit must be defined. | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| environment: | ||
| name: EmptyActions | ||
| script: | ||
| actions: {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: MinimalWrapEnv | ||
| script: | ||
| actions: | ||
| onEnter: | ||
| command: echo | ||
| args: ["entered"] | ||
| onWrapEnter: | ||
| command: echo | ||
| args: ["wrap-enter", "{{Env.Wrapped.Name}}"] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Going with |
||
| onWrapTaskRun: | ||
| command: echo | ||
| args: ["wrap-task", "{{WrappedAction.Command}}"] | ||
| onWrapExit: | ||
| command: echo | ||
| args: ["wrap-exit", "{{Env.Wrapped.Name}}"] | ||
| onExit: | ||
| command: echo | ||
| args: ["exited"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # RFC 0008: the three wrap hooks alone (without onEnter/onExit) are sufficient. | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: WrapOnlyEnv | ||
| script: | ||
| actions: | ||
| onWrapEnter: | ||
| command: echo | ||
| args: ["{{Env.Wrapped.Name}}"] | ||
| onWrapTaskRun: | ||
| command: echo | ||
| args: ["{{WrappedAction.Command}}"] | ||
| onWrapExit: | ||
| command: echo | ||
| args: ["{{Env.Wrapped.Name}}"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # RFC 0008: each wrap hook supports the same fields as any other Action, | ||
| # including timeout. The all-or-nothing rule requires all three hooks. | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: WrapWithTimeout | ||
| script: | ||
| actions: | ||
| onWrapEnter: | ||
| command: echo | ||
| args: ["{{Env.Wrapped.Name}}"] | ||
| timeout: 60 | ||
| onWrapTaskRun: | ||
| command: echo | ||
| args: ["{{WrappedAction.Command}}"] | ||
| timeout: 300 | ||
| onWrapExit: | ||
| command: echo | ||
| args: ["{{Env.Wrapped.Name}}"] | ||
| timeout: 60 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # RFC 0008 all-or-nothing rule: defining onWrapEnter and onWrapTaskRun | ||
| # without onWrapExit is invalid. | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: MissingWrapExit | ||
| script: | ||
| actions: | ||
| onWrapEnter: | ||
| command: echo | ||
| args: ["{{Env.Wrapped.Name}}"] | ||
| onWrapTaskRun: | ||
| command: echo | ||
| args: ["{{WrappedAction.Command}}"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # RFC 0008 all-or-nothing rule: an environment that defines any wrap hook | ||
| # must define all three (onWrapEnter, onWrapTaskRun, onWrapExit). Defining | ||
| # only onWrapTaskRun is invalid. | ||
| specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: PartialWrap | ||
| script: | ||
| actions: | ||
| onWrapTaskRun: | ||
| command: echo | ||
| args: ["{{WrappedAction.Command}}"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| # RFC 0008: WrappedAction.Environment is an empty list when no openjd_env | ||
| # was emitted in the session. Verifies the empty case doesn't trip the | ||
| # wrap script's iteration logic. | ||
| template: | ||
| specificationVersion: jobtemplate-2023-09 | ||
| name: WrappedActionEnvironmentEmpty | ||
| steps: | ||
| - name: Step1 | ||
| script: | ||
| actions: | ||
| onRun: | ||
| command: echo | ||
| args: ["placeholder"] | ||
|
|
||
| environments: | ||
| - specificationVersion: environment-2023-09 | ||
| extensions: | ||
| - WRAP_ACTIONS | ||
| - EXPR | ||
| environment: | ||
| name: WrapEnv | ||
| script: | ||
| actions: | ||
| onWrapEnter: | ||
| command: sh | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion change this to |
||
| args: | ||
| - "-c" | ||
| - "{{ repr_sh(WrappedAction.Command) }} {{ repr_sh(WrappedAction.Args) }}" | ||
| onWrapTaskRun: | ||
| command: sh | ||
| args: | ||
| - "-c" | ||
| # Print env count + a sentinel so we can confirm empty-list | ||
| # handling. With no openjd_env emissions, the loop body should | ||
| # not execute at all and only the sentinel appears. | ||
| - | | ||
| for e in {{ repr_sh(WrappedAction.Environment) }}; do | ||
| echo ENV_PRESENT=$e | ||
| done | ||
| echo ENV_DUMP_COMPLETE | ||
| onWrapExit: | ||
| command: sh | ||
| args: | ||
| - "-c" | ||
| - "{{ repr_sh(WrappedAction.Command) }} {{ repr_sh(WrappedAction.Args) }}" | ||
|
|
||
| runOn: | ||
| - posix | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this isn't posix-specific, better to run on all OS. |
||
|
|
||
| expected: | ||
| output: | ||
| - ENV_DUMP_COMPLETE | ||
| forbidden: | ||
| - ENV_PRESENT= | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should already fail because EXPR is missing, so it doesn't test what the comment says.