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).

@scottpledger

Copy link
Copy Markdown
Author

Hey, @dzbarsky! Any chance you will be able to look at this sometime soon? I now have an actual use-case for it and I'd love to get feedback on this approach before I start adopting it.

Comment thread cmd/svcinit/main.go Outdated
must(err)

ports, err := assignPorts(unversionedSpecs)
ports, portsMap, servicesMap, err := assignPorts(unversionedSpecs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

note to self: how is ports different from portsMap?

Comment thread cmd/svcinit/main.go Outdated
// legacy string->port map (for substitution / ASSIGNED_PORTS) and the rich maps.
register := func(serviceLabel, portName, domain, portStr, target string, aliases []string) {
info := svclib.BindingInfo{
Origin: domain + ":" + portStr,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

net.JoinHostPort

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

alternately, why not construct it on the fly when needed instead of storing twice?

Comment thread cmd/svcinit/main.go Outdated
s.Env[k] = strings.ReplaceAll(v, "$${PORT}", port)
}
}
if s.Env == nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

when does this happen? This feels like a bit ad-hoc place to normalize it, should it happen earlier if it's truly necessary?

Comment thread private/itest.bzl Outdated
bindings.append(struct(
target = label + "." + name,
name = name,
# Keep the legacy `:`-separated key working.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think we can do a separate pre-change to cleanup the :-separated legacy stuff, wdyt? I originally said I would break it in 2 months :) https://bazelbuild.slack.com/archives/C075VMU8T0Q/p1770213602739929

Comment thread private/itest.bzl
default = "127.0.0.1",
doc = """The host that this service's ports are reachable on. Defaults to `127.0.0.1` for locally-managed services.""",
),
"ports": attr.label_keyed_string_dict(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i wonder if we can make itest_port itself be a flag, wdyt? Would that allow collapsing this attr with port_values?

Comment thread private/itest.bzl Outdated

bindings = []
for port_target, name in ctx.attr.ports.items():
info = port_target[_PortInfo]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think if we do the above we can resume of these bad states/etc as well

Comment thread private/itest.bzl
value = ctx.attr.port_numbers[name],
))

extra_service_spec_kwargs = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

feels like we should reuse more of the code betwen internal/external, wdyt

// External services are not spawned by us, so there is no process to watch for exit.
// If a health check is configured, poll it; otherwise assume the service is reachable.
if s.Type == "external_service" {
if s.HttpHealthCheckAddress == "" && s.ServiceSpec.HealthCheck == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maybe add a s.PollUntilHealthy(ctx context.Context) method so we can reuse it?

@dzbarsky

Copy link
Copy Markdown
Member

Hey, @dzbarsky! Any chance you will be able to look at this sometime soon? I now have an actual use-case for it and I'd love to get feedback on this approach before I start adopting it.

Apologies for the delay, I've left some feedbacks. At a high level, I think the general idea makes sense, hopefully we can streamline the API a bit, but I don't think it would require major rework from you to migrate from how it's setup now to whatever the final state ends up, I expect it would be fairly close. I've also tagged in @darkrift to help review this as he is using this ruleset more actively than I am these days :)

@darkrift

darkrift commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

I very much like the global idea, while I don't have a use case for this yet, it's something that makes alot of sense. I'm currently a bit busy but will try to get my eyes on this by the end of the week

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.

3 participants