diff --git a/src/python/pants/core/util_rules/environments.py b/src/python/pants/core/util_rules/environments.py index b735a1d118b..baf653244a5 100644 --- a/src/python/pants/core/util_rules/environments.py +++ b/src/python/pants/core/util_rules/environments.py @@ -204,12 +204,33 @@ class RemoteExtraPlatformPropertiesField(StringSequenceField): ) +class RemoteFallbackEnvironmentField(StringField): + alias = "fallback_environment" + default = None + help = softwrap( + f""" + The environment to fallback to when remote execution is disabled via the global option + `--remote-execution`. + + Must be an environment name from the option `[environments-preview].names`, the + special string `{LOCAL_ENVIRONMENT_MATCHER}` to use the relevant local environment, or the + Python value `None` to error when remote execution is disabled. + + Tip: if you are using a Docker image with your remote execution environment (usually + enabled by setting the field {RemoteExtraPlatformPropertiesField.alias}`), then it can be + useful to fallback to an equivalent `docker_image` target so that you have a consistent + execution environment. + """ + ) + + class RemoteEnvironmentTarget(Target): alias = "_remote_environment" core_fields = ( *COMMON_TARGET_FIELDS, RemotePlatformField, RemoteExtraPlatformPropertiesField, + RemoteFallbackEnvironmentField, ) help = softwrap( """ @@ -252,6 +273,10 @@ class UnrecognizedEnvironmentError(Exception): pass +class RemoteExecutionDisabledError(Exception): + pass + + class AllEnvironmentTargets(FrozenDict[str, Target]): """A mapping of environment names to their corresponding environment target.""" @@ -382,7 +407,9 @@ async def determine_local_environment( @rule async def resolve_environment_name( - request: EnvironmentNameRequest, environments_subsystem: EnvironmentsSubsystem + request: EnvironmentNameRequest, + environments_subsystem: EnvironmentsSubsystem, + global_options: GlobalOptions, ) -> EnvironmentName: if request.raw_value == LOCAL_ENVIRONMENT_MATCHER: local_env_name = await Get(ChosenLocalEnvironmentName, {}) @@ -399,6 +426,37 @@ async def resolve_environment_name( """ ) ) + env_tgt = await Get(EnvironmentTarget, EnvironmentName(request.raw_value)) + if env_tgt.val is None: + raise AssertionError(f"EnvironmentTarget.val is None for the name `{request.raw_value}`") + + # If remote execution is disabled and it's a remote environment, try falling back. + if not global_options.remote_execution and env_tgt.val.has_field( + RemoteFallbackEnvironmentField + ): + fallback_field = env_tgt.val[RemoteFallbackEnvironmentField] + if fallback_field.value is None: + raise RemoteExecutionDisabledError( + softwrap( + f""" + The global option `--remote-execution` is set to false, but the remote + environment `{request.raw_value}` is used in {request.description_of_origin}. + + Either enable the option `--remote-execution`, or set the field + `{fallback_field.alias}` for the target {env_tgt.val.address}. + """ + ) + ) + return await Get( + EnvironmentName, + EnvironmentNameRequest( + fallback_field.value, + description_of_origin=( + f"the `{fallback_field.alias}` field of the target {env_tgt.val.address}" + ), + ), + ) + return EnvironmentName(request.raw_value) @@ -433,12 +491,16 @@ async def get_target_for_environment_name( WrappedTargetRequest(address, description_of_origin=_description_of_origin), ) tgt = wrapped_target.val - if not tgt.has_field(CompatiblePlatformsField) and not tgt.has_field(DockerImageField): + if ( + not tgt.has_field(CompatiblePlatformsField) + and not tgt.has_field(DockerImageField) + and not tgt.has_field(RemotePlatformField) + ): raise ValueError( softwrap( f""" - Expected to use the address to a `_local_environment` or `_docker_environment` - target in the option `[environments-preview].names`, but the name + Expected to use the address to a `_local_environment`, `_docker_environment`, or + `_remote_environment` target in the option `[environments-preview].names`, but the name `{env_name.val}` was set to the target {address.spec} with the target type `{tgt.alias}`. """ diff --git a/src/python/pants/core/util_rules/environments_test.py b/src/python/pants/core/util_rules/environments_test.py index 2300083b5bb..cfaafd9b829 100644 --- a/src/python/pants/core/util_rules/environments_test.py +++ b/src/python/pants/core/util_rules/environments_test.py @@ -24,6 +24,7 @@ LocalEnvironmentTarget, NoCompatibleEnvironmentError, RemoteEnvironmentTarget, + RemoteExecutionDisabledError, RemoteExtraPlatformPropertiesField, UnrecognizedEnvironmentError, extract_process_config_from_environment, @@ -44,7 +45,7 @@ def rule_runner() -> RuleRunner: QueryRule(EnvironmentTarget, [EnvironmentName]), QueryRule(EnvironmentName, [EnvironmentNameRequest]), ], - target_types=[LocalEnvironmentTarget, DockerEnvironmentTarget], + target_types=[LocalEnvironmentTarget, DockerEnvironmentTarget, RemoteEnvironmentTarget], singleton_environment=None, ) @@ -197,6 +198,9 @@ def test_resolve_environment_name(rule_runner: RuleRunner) -> None: # Intentionally set this to no platforms so that it cannot be autodiscovered. _local_environment(name='hardcoded', compatible_platforms=[]) _docker_environment(name='docker', image="centos6:latest") + _remote_environment(name='remote-no-fallback', fallback_environment=None) + _remote_environment(name='remote-fallback', fallback_environment="docker") + _remote_environment(name='remote-bad-fallback', fallback_environment="fake") """ ) } @@ -213,17 +217,29 @@ def get_name(v: str) -> EnvironmentName: with engine_error(UnrecognizedEnvironmentError): get_name("hardcoded") - rule_runner.set_options( - [ - "--environments-preview-names={'local': '//:local', 'hardcoded': '//:hardcoded', 'docker': '//:docker'}" - ] + env_names_arg = ( + "--environments-preview-names={" + + "'local': '//:local', " + + "'hardcoded': '//:hardcoded', " + + "'docker': '//:docker', " + + "'remote-no-fallback': '//:remote-no-fallback', " + + "'remote-fallback': '//:remote-fallback'," + "'remote-bad-fallback': '//:remote-bad-fallback'}" ) + rule_runner.set_options([env_names_arg, "--remote-execution"]) assert get_name(LOCAL_ENVIRONMENT_MATCHER).val == "local" - assert get_name("hardcoded").val == "hardcoded" - assert get_name("docker").val == "docker" + for name in ("hardcoded", "docker", "remote-no-fallback", "remote-fallback"): + assert get_name(name).val == name with engine_error(UnrecognizedEnvironmentError): get_name("fake") + rule_runner.set_options([env_names_arg, "--no-remote-execution"]) + assert get_name("remote-fallback").val == "docker" + with engine_error(RemoteExecutionDisabledError): + get_name("remote-no-fallback") + with engine_error(UnrecognizedEnvironmentError): + get_name("remote-bad-fallback") + def test_resolve_environment_tgt(rule_runner: RuleRunner) -> None: rule_runner.write_files({"BUILD": "_local_environment(name='env')"})