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
1 change: 1 addition & 0 deletions src/rpdk/core/rqts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""RQTS (CTv2 local) contract test execution support for ``cfn test --v2``."""
157 changes: 157 additions & 0 deletions src/rpdk/core/rqts/argv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Pure argv construction for the RQTS (CTv2 local) test runner.

This module is deliberately **side-effect free**: it performs no subprocess
calls, no filesystem or network I/O, and no Docker/AWS interaction. It only
transforms already-resolved inputs (image ref, project, region) into a
``docker run`` argument list, plus the separate process-environment mapping
(:func:`build_container_env`) that carries the credential values. Keeping both
pure makes them the units that the feature's property-based tests exercise
directly. Credential secrets never enter the argv: the ``-e`` flags are
name-only and docker resolves the values from the client process environment.

``cfn test --v2`` targets the published executor image's DirectJar handler
mode. The container command produced is::

--extension contract-tests run-tests /work/<artifact>.zip --direct-jar \
-r <region> -o <output-dir>

DirectJar loads the handler JAR directly into the executor JVM, so there is no
handler endpoint, no host networking, and no ``--handler-jar``/``-h``/``-tn``
flags. Inputs are packaged in the artifact zip and resolved by the executor, so
``-i`` is not emitted here. Scenario selection is owned by the executor image
(capability conditions plus namespace gating of the 1P-oriented ``tagging-*``
scenarios), so no ``-s``/``--scenarios`` and no exclusion flags are emitted.
"""

from .constants import CONTAINER_OUTPUT_DIR, CONTAINER_WORKDIR, EXTENSION_CONTRACT_TESTS

# Environment variable names the RQTS executor reads for AWS access. These are
# emitted as NAME-ONLY ``-e`` flags: docker resolves each value from the docker
# client process environment (supplied via ``run_container``'s ``env``), so no
# credential value ever appears in the argv - which makes the DEBUG-logged
# command line and ``ps`` output safe by construction.
# The B105 suppressions below are false positives: these literals are
# environment-variable NAMES and credential-dict KEYS, never secret values.
_ENV_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID"
_ENV_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" # nosec B105
_ENV_SESSION_TOKEN = "AWS_SESSION_TOKEN" # nosec B105
_ENV_REGION = "AWS_REGION"

# Keys used by ``boto_helpers.get_temporary_credentials`` (its default
# ``BOTO_CRED_KEYS``) to describe minted temporary credentials.
_CRED_ACCESS_KEY_ID = "aws_access_key_id"
_CRED_SECRET_ACCESS_KEY = "aws_secret_access_key" # nosec B105
_CRED_SESSION_TOKEN = "aws_session_token" # nosec B105


def _env_args():
"""Return the name-only ``-e`` flags for the AWS credential/region variables.

Values are deliberately NOT embedded: docker inherits each variable from
the docker client process environment, which the runner populates via
:func:`build_container_env`.
"""
return [
"-e",
_ENV_ACCESS_KEY_ID,
"-e",
_ENV_SECRET_ACCESS_KEY,
"-e",
_ENV_SESSION_TOKEN,
"-e",
_ENV_REGION,
]


def build_container_env(creds, region):
"""Build the environment mapping the docker client process must export.

This is the ONLY place credential secret values are threaded: the runner
merges this mapping into the spawned ``docker run`` process environment,
and the argv references the variables by name only.

:param creds: a mapping of minted temporary credentials using
``boto_helpers`` default keys (``aws_access_key_id``,
``aws_secret_access_key``, ``aws_session_token``).
:param str region: the effective AWS region.
:returns: dict of environment variable name -> value for the docker client.
"""
return {
_ENV_ACCESS_KEY_ID: creds[_CRED_ACCESS_KEY_ID],
_ENV_SECRET_ACCESS_KEY: creds[_CRED_SECRET_ACCESS_KEY],
_ENV_SESSION_TOKEN: creds[_CRED_SESSION_TOKEN],
_ENV_REGION: region,
}


def build_docker_argv(
image_ref,
project,
region,
workdir=None,
artifact_name=None,
output_dir=CONTAINER_OUTPUT_DIR,
):
"""Build the full ``docker run`` argv for the RQTS container (DirectJar).

The argv contains NO credential values by construction: the ``-e`` flags
are name-only, and the values travel through the docker client process
environment built by :func:`build_container_env`. This keeps the argv safe
to log at DEBUG (Req 4.10) and invisible to ``ps``.

Composition::

docker run --rm
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY
-e AWS_SESSION_TOKEN -e AWS_REGION
-v <workdir>:/work
<image_ref>
--extension contract-tests run-tests /work/<artifact_name>
--direct-jar -r <region> -o <output_dir>

:param str image_ref: the resolved RQTS container image reference.
:param project: the loaded :class:`rpdk.core.project.Project`.
``root`` is the default bind-mount source and ``hypenated_name`` names
the artifact zip.
:param str region: the effective AWS region (exported as ``AWS_REGION`` via
the process environment and passed via ``-r``).
:param workdir: host directory to bind-mount onto :data:`CONTAINER_WORKDIR`.
Defaults to ``str(project.root)``.
:param artifact_name: the handler artifact zip filename inside the mount.
Defaults to ``f"{project.hypenated_name}.zip"``.
:param output_dir: container-internal output directory passed via ``-o``.
Defaults to :data:`CONTAINER_OUTPUT_DIR`.
:returns: the complete ``docker run`` argument list.
"""
if workdir is None:
workdir = str(project.root)
if artifact_name is None:
artifact_name = f"{project.hypenated_name}.zip"

artifact_path_in_container = f"{CONTAINER_WORKDIR}/{artifact_name}"

argv = ["docker", "run", "--rm"]
# Credential/region variables by NAME only; values come from the process env.
argv += _env_args()
# Bind mount the working directory so the artifact zip is readable in-container.
argv += ["-v", f"{workdir}:{CONTAINER_WORKDIR}"]
# The image to run.
argv.append(image_ref)
# The executor command: top-level --extension selector, then the run-tests
# subcommand with the positional artifact path and DirectJar handler mode.
argv += [
"--extension",
EXTENSION_CONTRACT_TESTS,
"run-tests",
artifact_path_in_container,
"--direct-jar",
"-r",
region,
"-o",
output_dir,
]
# No -s/--scenarios and no --exclude-* flags: the executor image owns
# scenario selection (capability conditions + first-party namespace gating
# of the tagging-* scenarios), so 3P resources get the non-tagging subset
# automatically and 1P resources run the full suite.
return argv
47 changes: 47 additions & 0 deletions src/rpdk/core/rqts/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Constants for the RQTS (CTv2 local) contract test executor.

These values are owned by the CLI and pin the behavior of ``cfn test --v2``:
the RQTS Docker image reference, the executor extension selector, the image
pull retry policy, and the container-internal bind-mount root and output
directory.

``cfn test --v2`` targets the published executor image's DirectJar handler
mode, whose CLI contract is::

--extension contract-tests run-tests <artifact> --direct-jar -r <region> -o <output>

Input subsetting (``-i``) is intentionally NOT emitted: the executor uses the
inputs packaged inside the artifact zip. Scenario subsetting (``-s``) is NOT
emitted either: scenario selection is owned by the executor image, which gates
1P-oriented scenarios (``tagging-*``) by resource-type namespace, so the CLI
passes no scenario list and no exclusions.
"""

# Fully-qualified, CLI-pinned RQTS image on the ECR Public Gallery.
# ``s5r7m5i4`` is the permanent default registry alias of the publishing
# account. The mutable ``latest`` tag is followed deliberately: the cloud
# contract-test path always runs the latest published image, and the CLI keeps
# parity by attempting a pull on every run (falling back to the local cache
# when the registry is unreachable) instead of pinning per release.
RQTS_IMAGE_REFERENCE = "public.ecr.aws/s5r7m5i4/cfn-rqts-executor-external:latest"

# The executor top-level extension selector for contract tests. Passed as the
# top-level ``--extension`` option BEFORE the ``run-tests`` subcommand.
EXTENSION_CONTRACT_TESTS = "contract-tests"

# Image pull retry policy.
PULL_MAX_ATTEMPTS = 3
PULL_ATTEMPT_TIMEOUT_SECONDS = 120

# Container-internal bind-mount root.
CONTAINER_WORKDIR = "/work"

# Container-internal directory the executor writes its output to (under the
# bind mount, so results are visible on the host).
CONTAINER_OUTPUT_DIR = "/work/rqts-output"

# NOTE: the CLI deliberately owns NO scenario set. The executor image decides
# which scenarios run: capability conditions (taggable/creatable/...) plus
# namespace gating that restricts the 1P-oriented ``tagging-*`` scenarios to
# reserved first-party namespaces. 3P resources therefore get the non-tagging
# subset automatically, with no scenario or exclusion flags from the CLI.
159 changes: 159 additions & 0 deletions src/rpdk/core/rqts/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# have to skip B404, subprocess is required to drive the local Docker CLI
# have to skip B603/B607, docker is invoked with a fixed, non-shell argv
"""RQTS image resolution and pull-with-retry.

This module owns how ``cfn test --v2`` decides *which* RQTS container image to
run and how it makes that image available in the local Docker image store:

* :func:`resolve_image` picks the effective image reference (an explicit
``--rqts-image`` override, otherwise the CLI-pinned default).
* :func:`image_present_locally` reports whether the image is already in the
local Docker image store.
* :func:`ensure_image` attempts an anonymous ``docker pull`` on every run (the
pinned reference is a mutable ``latest`` tag, and the cloud contract-test
path always runs the latest published image, so local runs follow it for
parity) with a bounded per-attempt timeout and a capped number of attempts.
When every attempt fails it falls back to the local image store with a
warning if the image is cached, and raises
:class:`~rpdk.core.exceptions.SysExitRecommendedError` only when no cached
copy exists.

Docker is driven through the ``docker`` CLI via :mod:`subprocess`. The pull is
deliberately anonymous: no AWS credentials are ever supplied to it, because the
RQTS image is published to the ECR Public Gallery and is anonymously pullable.
"""
import logging
import subprocess # nosec B404

from rpdk.core.exceptions import SysExitRecommendedError

from .constants import (
PULL_ATTEMPT_TIMEOUT_SECONDS,
PULL_MAX_ATTEMPTS,
RQTS_IMAGE_REFERENCE,
)

LOG = logging.getLogger(__name__)


def resolve_image(args):
"""Resolve the effective RQTS image reference.

Returns the ``--rqts-image`` override when it is provided and non-empty,
otherwise the CLI-pinned :data:`RQTS_IMAGE_REFERENCE`.

:param args: parsed CLI arguments (expects an ``rqts_image`` attribute)
:return: the image reference to run
:rtype: str
"""
override = getattr(args, "rqts_image", None)
if override:
return override
return RQTS_IMAGE_REFERENCE


def _run_docker(docker_args, timeout=None):
"""Run a ``docker`` subcommand with a fixed, non-shell argv.

Kept as a small internal seam so tests can patch a single call site. No AWS
credentials are ever injected here; the child inherits the ambient
environment only.

:param list docker_args: arguments following ``docker`` (for example
``["image", "inspect", ref]``)
:param timeout: optional per-attempt timeout in seconds
:return: the completed process
:rtype: subprocess.CompletedProcess
"""
return subprocess.run( # nosec B603 B607
["docker", *docker_args],
check=False,
capture_output=True,
timeout=timeout,
)


def image_present_locally(image_ref):
"""Return whether ``image_ref`` is present in the local Docker image store.

Uses ``docker image inspect <ref>``, which exits non-zero when the image is
not present locally.

:param str image_ref: the image reference to look up
:return: ``True`` when the image is available locally, ``False`` otherwise
:rtype: bool
"""
try:
completed = _run_docker(["image", "inspect", image_ref])
except (OSError, subprocess.SubprocessError) as err:
LOG.debug("Local image inspect for %s failed: %s", image_ref, err)
return False
return completed.returncode == 0


def ensure_image(image_ref):
"""Ensure ``image_ref`` is available locally, refreshing it when possible.

A pull is attempted on **every** invocation so that a moved mutable tag
(for example ``latest``) is picked up without manual ``docker pull``.
The pull is anonymous (no AWS credentials) with a bounded per-attempt
timeout of :data:`PULL_ATTEMPT_TIMEOUT_SECONDS`, retrying up to
:data:`PULL_MAX_ATTEMPTS` times and stopping on the first success. An
up-to-date image costs only a manifest check; no layers are re-downloaded.

When every attempt fails, the run falls back to the local image store if
the image is cached there (with a warning that it may be stale); only when
no cached copy exists is the failure fatal.

:param str image_ref: the resolved RQTS image reference
:raises SysExitRecommendedError: if the pull fails on every attempt and
the image is not present in the local image store
"""
LOG.info("Pulling RQTS image %s (anonymous pull)", image_ref)
last_error = None
for attempt in range(1, PULL_MAX_ATTEMPTS + 1):
LOG.debug(
"Pulling RQTS image %s (attempt %d of %d)",
image_ref,
attempt,
PULL_MAX_ATTEMPTS,
)
try:
completed = _run_docker(
["pull", image_ref], timeout=PULL_ATTEMPT_TIMEOUT_SECONDS
)
except subprocess.TimeoutExpired:
last_error = f"timed out after {PULL_ATTEMPT_TIMEOUT_SECONDS}s"
LOG.debug("Pull attempt %d for %s %s", attempt, image_ref, last_error)
continue
except (OSError, subprocess.SubprocessError) as err:
last_error = str(err)
LOG.debug(
"Pull attempt %d for %s failed: %s", attempt, image_ref, last_error
)
continue

if completed.returncode == 0:
LOG.debug(
"Successfully pulled RQTS image %s on attempt %d", image_ref, attempt
)
return

last_error = (completed.stderr or b"").decode("utf-8", "replace").strip() or (
f"docker pull exited with code {completed.returncode}"
)
LOG.debug("Pull attempt %d for %s failed: %s", attempt, image_ref, last_error)

if image_present_locally(image_ref):
LOG.warning(
"Could not pull the RQTS image '%s' (%s); using the locally cached "
"copy, which may be stale",
image_ref,
last_error,
)
return

raise SysExitRecommendedError(
f"Failed to pull the RQTS image '{image_ref}' after "
f"{PULL_MAX_ATTEMPTS} attempts: {last_error}"
)
Loading
Loading