Skip to content

feat: Allow for external services and declarative port binding#69

Open
scottpledger wants to merge 1 commit into
hermeticbuild:masterfrom
scottpledger:feat/external-services
Open

feat: Allow for external services and declarative port binding#69
scottpledger wants to merge 1 commit into
hermeticbuild:masterfrom
scottpledger:feat/external-services

Conversation

@scottpledger

@scottpledger scottpledger commented Jun 16, 2026

Copy link
Copy Markdown

Allows services to be described in more detail than just as a local port, so a test suite can run against either locally-managed services or production-like instances (selected with Bazel select()).

Important changes/features

  • itest_port — declare a port as a first-class target. The binding info (value + host) is supplied by whichever service binds it, and a port can only be bound once.
  • itest_external_service — point at a fixed FQDN instead of a locally-spawned binary. It exposes the same provider as itest_service, so it's a drop-in replacement via select(). The manager never starts/stops it (optional health check only).
  • Hostname/domain supportitest_service gains domain (default 127.0.0.1) and a ports attribute.
  • New env vars ITEST_PORTS_MAP and ITEST_SERVICES_MAP (string-encoded JSON) describe every port/service with {origin, domain, port}, injected into the test and all child services.
  • Control API GET /v0/ports and GET /v0/services list this for all services at once.

I also made this fully backward-compatible: existing macros auto-create the needed ports/aliases; port(), ASSIGNED_PORTS, GET_ASSIGNED_PORT_BIN, /v0/port, and --//pkg:svc.port=N overrides all still work. The underlying rules are now exported for extension.

Example

load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
load(
    "@rules_itest//:itest.bzl",
    "itest_port",
    "itest_service",
    "itest_external_service",
    "service_test",
    "port_ref",
)
# Declare a port as a target.
itest_port(name = "db_port")
# A locally-managed service binds it.
itest_service(
    name = "db",
    exe = "//db:server",
    ports = {":db_port": "sql"},
    args = ["-port", port_ref(":db_port")],
    http_health_check_address = "http://127.0.0.1:" + port_ref(":db_port"),
)
# A production-like instance exposes the same port at a fixed FQDN.
itest_external_service(
    name = "db_external",
    domain = "db.staging.mycompany.com",
    ports = {":db_port": "sql"},
    port_numbers = {"sql": "5432"},
)
# A flag to choose which implementation to test against.
bool_flag(
    name = "use_external_db",
    build_setting_default = False,
)
config_setting(
    name = "external",
    flag_values = {":use_external_db": "True"},
)
# Swap between them with the flag:
#   bazel test //myapp:db_test                              # local service
#   bazel test --//myapp:use_external_db //myapp:db_test       # production-like instance
service_test(
    name = "db_test",
    test = ":_db_test",
    services = select({
        ":external": [":db_external"],
        "//conditions:default": [":db"],
    }),
)

The test can then read the connection info from either:

  • ITEST_PORTS_MAP (or /v0/ports) (keyed by port target label, plus any aliases):
    {
      "@@//myapp:db_port": { "origin": "127.0.0.1:54321", "domain": "127.0.0.1", "port": "54321" }
    }
    Or, if run with --//myapp:use_external_db, the same keys instead resolve to the production-like instance:
    {
      "@@//myapp:db_port": { "origin": "db.staging.mycompany.com:5432", "domain": "db.staging.mycompany.com", "port": "5432" }
    }
  • ITEST_SERVICES_MAP (or /v0/services), e.g.:
    {
      "@@//myapp:db": { "sql": { "origin": "127.0.0.1:54321", "domain": "127.0.0.1", "port": "54321" } }
    }

Note: in the example above, :db_external and :db both bind :db_port — that's valid because select() resolves to only one of them at a time in the service_test rule, so the port is still only bound once.

Testing

Added tests/ports, which covers internal ports, an external service, the select() swap, the new maps/endpoints, and ensuring ports & aliases are only ever bound once. The full test suite passes (48/48), and the examples build cleanly.

Caveats

  • This code does not automatically cache-bust when using remote services. This is intentional at present, though we could probably add this in a future implementation.
  • This code does not allow for specifying subpaths or other similar service/port metadata. For example, if a local server serves an app from root, but a remote one serves it from /my/app or something. I don't think this would be too hard to add in a follow-up, though.

Solves: #70

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d42ba6037

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread runner/runner.go
Comment on lines +66 to +67
if service.Type == "external_service" {
return service.WaitUntilHealthy(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor health_check_timeout for external services

When an itest_external_service has an HTTP or command health check that keeps failing and health_check_timeout is set, this early return calls WaitUntilHealthy before the timeout-wrapping block below runs. The external-service health loop therefore polls until the outer Bazel/test timeout instead of failing after the configured service timeout, which makes unreachable external dependencies hang much longer than requested.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed.

Comment thread private/itest.bzl
"so_reuseport_aware": ctx.attr.so_reuseport_aware,
"deferred": ctx.attr.deferred,
"domain": ctx.attr.domain,
"port_bindings": _compute_port_bindings(ctx),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow SO_REUSEPORT with declarative ports

When a service uses only the new ports = {":p": "name"} binding, _compute_port_bindings(ctx) still creates an autoassigned socket, but the validation above only allows so_reuseport_aware with legacy autoassign_port or named_ports. As a result, users adopting first-class itest_port targets cannot enable the collision-avoidance mode for those autoassigned ports and get an analysis failure even though the runtime path supports SoReuseportAware for all bindings.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed.

@dzbarsky

Copy link
Copy Markdown
Member

Thanks for sending this! I've definitely considered a first class port target in the past and I'm excited to look at what you've cooked up here. It might take me a bit to get to it but wanted to ack the PR

@scottpledger scottpledger force-pushed the feat/external-services branch from 3d42ba6 to 7337a01 Compare June 16, 2026 17:50
@scottpledger

Copy link
Copy Markdown
Author

Thanks for sending this! I've definitely considered a first class port target in the past and I'm excited to look at what you've cooked up here. It might take me a bit to get to it but wanted to ack the PR

No problem! This is something I've been thinking about for a while, given how we use this library at my company. We currently just create multiple test targets - one for local, test, preview, and prod. However, this approach doesn't give us the flexibility to mix and match service locations (eg, a local web server with test remote APIs).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants