Skip to content

Commit e4026da

Browse files
committed
ci: add static validation checks for sample assets
Adds an offline pytest suite (tests/) and a GitHub Actions workflow that runs it on pull requests, so problems in the samples are caught in CI instead of by a customer. The motivating case: a fleet host configuration script that exceeds the service's 15000-character scriptBody limit looks fine in the repo but fails the moment a customer applies it. Checks: - OpenJD job and environment templates pass 'openjd check'. - Host configuration scripts stay within the service scriptBody size limit; Linux *.sh pass 'bash -n' and Windows *.ps1 parse with the PowerShell parser. - Queue environment templates stay within the EnvironmentTemplate size limit. - CloudFormation templates parse (incl. !Sub/!Ref intrinsics) and pass cfn-lint. - Conda recipes: deadline-cloud.yaml schema + buildTool/recipe-file agreement; rattler-build recipes validated with 'rattler-build build --render-only' (real, offline validation, no dependency solve); conda-build meta.yaml rendered (Jinja + selectors) and structurally validated offline. 'conda render' is not used because it requires a network dependency solve. No skips: every external tool (openjd, cfn-lint, rattler-build, bash, pwsh) is required. A missing tool fails the run rather than skipping, because a skipped check is indistinguishable from a passing one. The workflow installs all tools (rattler-build pinned by version + SHA-256) and verifies them before the run. Service limits are pinned to the AWS Deadline Cloud API model in tests/service_limits.py. The workflow caches pip installs keyed on tests/requirements.txt. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
1 parent ff3c32e commit e4026da

11 files changed

Lines changed: 887 additions & 0 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Static Checks
2+
3+
# Runs fast, offline static validation of the sample assets (OpenJD templates,
4+
# host configuration scripts, CloudFormation templates, and Conda recipes) so
5+
# that problems -- for example a host configuration script that exceeds the
6+
# service's size limit -- are caught in CI instead of by a customer.
7+
#
8+
# Every external validator (openjd, cfn-lint, rattler-build, bash, pwsh) is
9+
# installed below. The test suite is designed to FAIL rather than skip when a
10+
# tool is missing, so if an install step regresses the checks turn red instead
11+
# of silently passing.
12+
13+
on:
14+
push:
15+
branches: ["mainline"]
16+
pull_request:
17+
branches: ["mainline"]
18+
19+
permissions:
20+
contents: read
21+
22+
concurrency:
23+
group: static-checks-${{ github.ref }}
24+
cancel-in-progress: true
25+
26+
env:
27+
# rattler-build is a standalone binary (not on PyPI). Pin the version and the
28+
# SHA-256 of the Linux x86_64 binary so the download is reproducible and
29+
# tamper-evident.
30+
RATTLER_BUILD_VERSION: "0.68.0"
31+
RATTLER_BUILD_SHA256: "09182ae841bd803435c8ed34017811522d18a709bb727bb8e31241119562ca60"
32+
33+
jobs:
34+
static-checks:
35+
name: Static validation
36+
# ubuntu-latest ships with both bash and PowerShell (pwsh) preinstalled,
37+
# which the host configuration script syntax checks require.
38+
runs-on: ubuntu-latest
39+
steps:
40+
- name: Check out repository
41+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
42+
43+
- name: Set up Python
44+
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
45+
with:
46+
python-version: "3.12"
47+
# Cache pip downloads keyed on the test requirements file so the
48+
# openjd-cli / cfn-lint install is restored from cache on unchanged runs.
49+
cache: pip
50+
cache-dependency-path: tests/requirements.txt
51+
52+
- name: Install Python test dependencies
53+
run: |
54+
python -m pip install --upgrade pip
55+
python -m pip install -r tests/requirements.txt
56+
57+
- name: Install rattler-build
58+
run: |
59+
set -euo pipefail
60+
url="https://github.com/prefix-dev/rattler-build/releases/download/v${RATTLER_BUILD_VERSION}/rattler-build-x86_64-unknown-linux-musl"
61+
curl --fail --location --silent --show-error --output rattler-build "$url"
62+
echo "${RATTLER_BUILD_SHA256} rattler-build" | sha256sum --check --status
63+
chmod +x rattler-build
64+
sudo mv rattler-build /usr/local/bin/rattler-build
65+
rattler-build --version
66+
67+
- name: Verify required tools are present
68+
# Fail early with a clear message if any validator is missing, rather
69+
# than discovering it partway through the test run.
70+
run: |
71+
set -euo pipefail
72+
for tool in openjd cfn-lint rattler-build bash pwsh; do
73+
if ! command -v "$tool" >/dev/null 2>&1; then
74+
echo "::error::required tool '$tool' is not installed"
75+
exit 1
76+
fi
77+
echo "found $tool: $(command -v "$tool")"
78+
done
79+
80+
- name: Run static checks
81+
working-directory: tests
82+
run: python -m pytest -v

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ conda_recipes/archive_files/vraystd_*
1515
**/*.exe
1616
**/*.run
1717
**/*.pyc
18+
**/__pycache__/
19+
.pytest_cache/
1820
**/*.aex
1921
**/*.conda
2022
**/*.safetensors

tests/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Static validation tests
2+
3+
Static, offline checks for the sample assets in this repository. They run in CI
4+
on every pull request (see `.github/workflows/static_checks.yml`) and can be run
5+
locally with:
6+
7+
```bash
8+
python -m pip install -r tests/requirements.txt
9+
python -m pytest tests -v
10+
```
11+
12+
These checks exist to catch problems *before* a customer does. They are
13+
deliberately fast and require no AWS credentials or network access.
14+
15+
## No skips
16+
17+
Every check that shells out to an external tool (`openjd`, `cfn-lint`,
18+
`rattler-build`, `bash`, `pwsh`) **fails** if that tool is missing -- it is
19+
never skipped. A skipped check looks the same as a passing one in the CI
20+
summary, which is precisely how a bad sample slips through. The CI workflow
21+
installs all of these tools and verifies they are present before running the
22+
suite. To run locally, install the tools listed in
23+
[`requirements.txt`](./requirements.txt) plus `rattler-build`, `bash`, and
24+
`pwsh` (PowerShell).
25+
26+
## What is checked
27+
28+
| Area | File | Check |
29+
|------|------|-------|
30+
| Open Job Description job & environment templates | `test_openjd_templates.py` | Every standalone template with an OpenJD `specificationVersion` passes `openjd check`. |
31+
| Host configuration scripts | `test_host_configuration_scripts.py` | Byte length is within the Deadline Cloud service limit (`HostConfiguration.scriptBody` max **15000**); Linux (`*.sh`) scripts pass `bash -n`; Windows (`*.ps1`) scripts parse with the PowerShell parser. |
32+
| Queue environments | `test_openjd_templates.py` | Serialized `environment-2023-09` templates are within the service limit for `EnvironmentTemplate` (max **15000**). |
33+
| CloudFormation templates | `test_cloudformation.py` | Templates parse as CloudFormation YAML (intrinsic tags such as `!Sub`/`!Ref` supported) and pass `cfn-lint` (errors only). |
34+
| Conda recipes | `test_conda_recipes.py` | `deadline-cloud.yaml` matches the expected schema and its `buildTool` has a matching recipe file; **rattler-build** recipes (`recipe.yaml`) are validated with `rattler-build build --render-only`; **conda-build** recipes (`meta.yaml`) are rendered (Jinja + `# [selector]`) and structurally validated offline. |
35+
36+
A few recipes are deliberately fill-in-the-blanks templates that ship a
37+
placeholder source checksum for the user to replace (e.g.
38+
`blender-plugin-bundle`). `rattler-build` rejects a non-hex placeholder, so the
39+
check substitutes a syntactically valid dummy checksum into a temporary copy
40+
before rendering — the full recipe is still validated, only the
41+
intentionally-blank checksum field is normalized. Genuinely invalid recipes
42+
(unknown fields, bad structure) still fail.
43+
44+
### Why not `conda render` for `meta.yaml`?
45+
46+
`conda-build`'s own `conda render` resolves dependencies against remote conda
47+
channels, which needs network access and is non-deterministic (a solve can
48+
start failing when an upstream package changes). That is a poor fit for a fast,
49+
offline CI check, so `meta.yaml` is validated by rendering its Jinja/selectors
50+
and checking the resulting document structure. `rattler-build --render-only`, by
51+
contrast, validates fully offline without a dependency solve, so it is used
52+
directly.
53+
54+
## Where the limits come from
55+
56+
The numeric limits in `service_limits.py` are taken from the AWS Deadline Cloud
57+
API model (the `deadline` botocore service definition, API version
58+
`2023-10-12`). The most important one for this repository is
59+
`HostConfiguration.scriptBody`, whose maximum length is **15000** characters — a
60+
host configuration script that exceeds it is rejected by `UpdateFleet`, which is
61+
exactly the class of failure these checks are meant to catch early.

tests/conftest.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
"""Shared discovery helpers for the static validation tests.
3+
4+
Everything here is filesystem-only so the tests run fast and need no network or
5+
AWS credentials. Discovery walks the repository from its root (the parent of the
6+
``tests`` directory) and deliberately skips directories that hold scratch or
7+
vendored copies (for example ``.claude`` worktrees and ``.git``) so those never
8+
affect CI results.
9+
"""
10+
from __future__ import annotations
11+
12+
import re
13+
import shutil
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
REPO_ROOT = Path(__file__).resolve().parent.parent
19+
20+
21+
def require_tool(name: str, install_hint: str) -> str:
22+
"""Return the path to a required executable, or FAIL the test if it is absent.
23+
24+
These checks are meant to run in CI where every required tool is installed.
25+
A missing tool always fails -- it is never skipped -- because a skipped
26+
check is indistinguishable from a passing one and is exactly how a bad
27+
sample slips through. There is deliberately no environment-variable escape
28+
hatch: run the checks with the tools installed, or they fail.
29+
"""
30+
path = shutil.which(name)
31+
if path:
32+
return path
33+
pytest.fail(
34+
f"required tool {name!r} is not installed. Install it with: {install_hint}",
35+
pytrace=False,
36+
)
37+
38+
# Directories anywhere in the tree whose contents are not part of the samples we
39+
# ship and should never be validated.
40+
_EXCLUDED_DIR_NAMES = {".git", ".claude", ".kiro", "node_modules", "__pycache__", "build"}
41+
42+
# Matches the OpenJD ``specificationVersion`` header of a standalone template.
43+
# Anchored at column 0 (no leading whitespace) on purpose: a standalone OpenJD
44+
# template file has this as a top-level key, whereas a template *embedded* inside
45+
# another document (for example an environment template nested in a
46+
# CloudFormation resource) is indented. Only standalone template files are
47+
# validated with ``openjd check``.
48+
_SPEC_VERSION_RE = re.compile(
49+
r"""^specificationVersion\s*:\s*['"]?(?P<version>[A-Za-z0-9._-]+)""",
50+
re.MULTILINE,
51+
)
52+
53+
54+
def _is_excluded(path: Path) -> bool:
55+
return any(part in _EXCLUDED_DIR_NAMES for part in path.parts)
56+
57+
58+
def _iter_yaml_files() -> list[Path]:
59+
files = []
60+
for pattern in ("*.yaml", "*.yml"):
61+
for path in REPO_ROOT.rglob(pattern):
62+
if not _is_excluded(path.relative_to(REPO_ROOT)):
63+
files.append(path)
64+
return sorted(set(files))
65+
66+
67+
def spec_version(path: Path) -> str | None:
68+
"""Return the OpenJD ``specificationVersion`` of a YAML file, or ``None``.
69+
70+
Reads only the head of the file; the header appears at the top of every
71+
OpenJD template.
72+
"""
73+
try:
74+
head = path.read_text(encoding="utf-8", errors="replace")[:4096]
75+
except OSError:
76+
return None
77+
match = _SPEC_VERSION_RE.search(head)
78+
return match.group("version") if match else None
79+
80+
81+
def find_openjd_templates() -> list[Path]:
82+
"""All OpenJD job and environment templates in the repository."""
83+
return [
84+
p
85+
for p in _iter_yaml_files()
86+
if (v := spec_version(p)) is not None
87+
and (v.startswith("jobtemplate-") or v.startswith("environment-"))
88+
]
89+
90+
91+
def find_job_templates() -> list[Path]:
92+
return [p for p in find_openjd_templates() if (spec_version(p) or "").startswith("jobtemplate-")]
93+
94+
95+
def find_environment_templates() -> list[Path]:
96+
return [p for p in find_openjd_templates() if (spec_version(p) or "").startswith("environment-")]
97+
98+
99+
def find_host_configuration_scripts() -> list[Path]:
100+
"""Host configuration shell / PowerShell scripts."""
101+
base = REPO_ROOT / "host_configuration_scripts"
102+
if not base.is_dir():
103+
return []
104+
scripts = []
105+
for pattern in ("*.sh", "*.ps1"):
106+
for path in base.rglob(pattern):
107+
if not _is_excluded(path.relative_to(REPO_ROOT)):
108+
scripts.append(path)
109+
return sorted(set(scripts))
110+
111+
112+
def find_cloudformation_templates() -> list[Path]:
113+
"""CloudFormation templates (YAML files under ``cloudformation/``).
114+
115+
Excludes the OpenJD job templates that happen to live under that tree (for
116+
example the ``test-job.yaml`` samples) since those are validated by the
117+
OpenJD checks instead.
118+
"""
119+
base = REPO_ROOT / "cloudformation"
120+
if not base.is_dir():
121+
return []
122+
templates = []
123+
for pattern in ("*.yaml", "*.yml"):
124+
for path in base.rglob(pattern):
125+
rel = path.relative_to(REPO_ROOT)
126+
if _is_excluded(rel) or spec_version(path) is not None:
127+
continue
128+
templates.append(path)
129+
return sorted(set(templates))
130+
131+
132+
def find_conda_recipe_dirs() -> list[Path]:
133+
"""Directories under ``conda_recipes/`` that contain a ``deadline-cloud.yaml``."""
134+
base = REPO_ROOT / "conda_recipes"
135+
if not base.is_dir():
136+
return []
137+
dirs = []
138+
for path in base.rglob("deadline-cloud.yaml"):
139+
rel = path.relative_to(REPO_ROOT)
140+
if not _is_excluded(rel):
141+
dirs.append(path.parent)
142+
return sorted(set(dirs))
143+
144+
145+
def rel(path: Path) -> str:
146+
"""Repo-relative string form of a path, for readable test ids."""
147+
try:
148+
return str(path.relative_to(REPO_ROOT))
149+
except ValueError:
150+
return str(path)

tests/pytest.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[pytest]
2+
# Run from the tests/ directory so `import conftest`, `service_limits`, etc.
3+
# resolve without packaging the samples repo.
4+
testpaths = .
5+
python_files = test_*.py
6+
addopts = -ra

tests/requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Dependencies for the static validation test suite (tests/).
2+
# Pinned loosely; CI caches the resolved install (see the static_checks workflow).
3+
pytest>=7,<10
4+
PyYAML>=6,<7
5+
Jinja2>=3,<4
6+
openjd-cli>=0.7,<1
7+
cfn-lint>=1,<2
8+
# NOTE: rattler-build is a standalone binary (not on PyPI). CI installs it with
9+
# the prefix-dev/setup-rattler-build action; see .github/workflows/static_checks.yml.

tests/service_limits.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
"""AWS Deadline Cloud service limits relevant to the sample assets.
3+
4+
These values come from the AWS Deadline Cloud API model (the ``deadline``
5+
botocore service definition, API version ``2023-10-12``). They are the hard
6+
limits the service enforces on request inputs; a sample that exceeds one of them
7+
will be rejected by the corresponding API call. Keeping the checks pinned to the
8+
documented service limits is what lets CI catch the problem before a customer
9+
does.
10+
"""
11+
12+
# Maximum length, in characters, of a fleet host configuration script.
13+
# Model shape: HostConfigurationScript (string, max=15000).
14+
# Used by UpdateFleet's hostConfiguration.scriptBody. A script longer than this
15+
# is rejected by the service -- this is the limit that previously slipped
16+
# through to a customer.
17+
HOST_CONFIGURATION_SCRIPT_MAX_CHARS = 15000
18+
19+
# Bounds, in seconds, on HostConfiguration.scriptTimeoutSeconds.
20+
# Model shape: HostConfigurationScriptTimeoutSeconds (integer, min=300, max=3600).
21+
HOST_CONFIGURATION_SCRIPT_TIMEOUT_MIN_SECONDS = 300
22+
HOST_CONFIGURATION_SCRIPT_TIMEOUT_MAX_SECONDS = 3600
23+
24+
# Maximum length, in characters, of a serialized queue environment template
25+
# passed to the service. Model shape: EnvironmentTemplate (string, max=15000).
26+
ENVIRONMENT_TEMPLATE_MAX_CHARS = 15000
27+
28+
# Maximum length, in characters, of a serialized job template passed to
29+
# CreateJob. Model shape: JobTemplate (string, max=1000000).
30+
JOB_TEMPLATE_MAX_CHARS = 1000000

0 commit comments

Comments
 (0)