feat: Allow for external services and declarative port binding - #69
scottpledger wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
💡 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".
| if service.Type == "external_service" { | ||
| return service.WaitUntilHealthy(ctx) |
There was a problem hiding this comment.
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 👍 / 👎.
| "so_reuseport_aware": ctx.attr.so_reuseport_aware, | ||
| "deferred": ctx.attr.deferred, | ||
| "domain": ctx.attr.domain, | ||
| "port_bindings": _compute_port_bindings(ctx), |
There was a problem hiding this comment.
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 👍 / 👎.
|
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 |
3d42ba6 to
7337a01
Compare
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). |
|
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. |
| // 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, |
There was a problem hiding this comment.
alternately, why not construct it on the fly when needed instead of storing twice?
There was a problem hiding this comment.
Done — Origin is built with net.JoinHostPort(hostname, portStr) now. (e0f453c)
There was a problem hiding this comment.
origin is part of the documented public schema of ITEST_PORTS_MAP/ITEST_SERVICES_MAP ({origin, hostname, port}) and backs the $${…::origin} substitution token, so consumers get it without re-joining host+port (or re-implementing IPv6 bracketing). Since it has to be in the serialized output regardless, I kept it materialized on BindingInfo rather than reconstructing it at each use site — but happy to compute-on-serialize instead if you'd rather not carry the field.
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 :) |
|
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 |
7337a01 to
c816346
Compare
| return _finalize_service(ctx, service, transitive_runfiles = _services_runfiles(ctx, "deps") + extra_exe_runfiles) | ||
|
|
||
| _itest_external_service_attrs = { | ||
| "domain": attr.string( |
There was a problem hiding this comment.
This should be renamed to hostname which is more common than domain
There was a problem hiding this comment.
Done — renamed domain → hostname on both itest_service and itest_external_service, and threaded it through the Go ServiceSpec/BindingInfo fields, the ITEST_PORTS_MAP/ITEST_SERVICES_MAP key, the port_hostname helper + $${…::hostname} token, and the tests/docs. Bonus: hostname matches net/url.URL.Hostname() semantics (host-or-IP minus the port), where domain was misleading. (e0f453c)
| "deferred": attr.bool( | ||
| doc = "If set, the external service will not be health-checked on boot up.", | ||
| ), | ||
| "http_health_check_address": attr.string( |
There was a problem hiding this comment.
It should have the same command line for health check if the service uses a different protocol to validate it's health
There was a problem hiding this comment.
Agreed — for non-HTTP protocols a command health check is the right tool. itest_external_service already exposes health_check + health_check_args (mirroring itest_service), and I've now added an example that exercises it (see the cmd_health_db reply below). (e0f453c)
| func (s *ServiceInstance) PollUntilHealthy(ctx context.Context) error { | ||
| sleepDuration, err := time.ParseDuration(s.HealthCheckInterval) | ||
| if err != nil { | ||
| sleepDuration = 200 * time.Millisecond |
There was a problem hiding this comment.
This should be a default value in the rule instead
There was a problem hiding this comment.
Done — the health_check_interval default (200ms) lives in the rule and is validated in Starlark, so I dropped the hardcoded fallback in PollUntilHealthy. It now returns an error on a malformed value instead of silently defaulting. (e0f453c)
| Deferred bool `json:"deferred"` | ||
| // Domain is the host that this service's ports are reachable on. Internal | ||
| // services default to "127.0.0.1"; external services set it to their FQDN. | ||
| Domain string `json:"domain"` |
There was a problem hiding this comment.
Done — renamed the field to Hostname (json hostname) as part of the same domain→hostname rename. (e0f453c)
| bindings.append(_port_binding(label, "", [], str(ctx.attr.port[BuildSettingInfo].value))) | ||
|
|
||
| for port_flag, name in ctx.attr.named_ports.items(): | ||
| bindings.append(_port_binding(label + "." + name, name, [], str(port_flag[BuildSettingInfo].value))) |
There was a problem hiding this comment.
use _to_relative_named_port instead of concatenating "."
There was a problem hiding this comment.
Good call. _to_relative_named_port lives in //:itest.bzl and relies on native.package_relative_label, which is loading-phase only, so it can't run inside a rule implementation. I added an analysis-phase counterpart _named_port_target(label, name) in private/itest.bzl and use it there; both share the <label>.<name> convention (noted in a comment so they stay in sync). (e0f453c)
| name = "external_db", | ||
| domain = "db.test.invalid", | ||
| port_numbers = {"sql": "5432"}, | ||
| ports = {":ext_db_port": "sql"}, |
There was a problem hiding this comment.
This is an example of where a cmd health check would come useful to know if the database is available but doesn't have an http endpoint for health check
There was a problem hiding this comment.
Added exactly this: a new cmd_health_db external service with no HTTP endpoint whose readiness is verified by a command health check — a small tcp_probe binary that dials the (locally-managed, to stay hermetic) DB port. It's covered by //ports:cmd_health_db_hygiene_test. (e0f453c)
|
@scottpledger Sorry for the late review, I just did a pass, I agree with all of David's original review and added a few more to it. |
Glad to see the recent review -- we definitely have a need for this feature and I came to just check in to see if I could help push this along. Looking forward to seeing it landed! |
|
Hey y'all, I'm so sorry for the delays — I've had several large issues hogging my resources. I'm hoping to get back to this next week. |
…vices Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # cmd/svcinit/main.go # docs/itest.md
… checks - Rename `domain` -> `hostname` across the Starlark attrs, Go ServiceSpec/ BindingInfo fields, the ITEST_PORTS_MAP/ITEST_SERVICES_MAP key, the `port_domain`->`port_hostname` helper and `::domain`->`::hostname` token, tests, and docs. `hostname` matches `net/url.URL.Hostname()` semantics (host-or-IP minus the port), whereas `domain` was misleading. - Drop the hardcoded 200ms fallback in ServiceInstance.PollUntilHealthy and rely on the rule-provided (and validated) health_check_interval default. - Add a shared `_named_port_target` helper instead of ad-hoc `label + "." + name` concatenation when building named-port bindings. - Demonstrate command-based health checks for itest_external_service with a new tcp_probe example (a DB-style service with no HTTP endpoint), verified by a hygiene test. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Sorry for the long delay on this — I've pushed an update (e0f453c) that brings the PR current and works through the latest review. Upstream's port-reservation refactor ( Let me know your thoughts. I should note that I used Claude for a fair amount of this since my Go knowledge is pretty sparse (the last time I did much Go code was in 2009 when it first came out 😬 ). |
…vices Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # go.sum # runner/runner.go
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 asitest_service, so it's a drop-in replacement viaselect(). The manager never starts/stops it (optional health check only).itest_servicegains domain (default127.0.0.1) and aportsattribute.ITEST_PORTS_MAPandITEST_SERVICES_MAP(string-encoded JSON) describe every port/service with{origin, domain, port}, injected into the test and all child services.GET /v0/portsandGET /v0/serviceslist 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=Noverrides all still work. The underlying rules are now exported for extension.Example
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" } }--//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_externaland:dbboth bind:db_port— that's valid becauseselect()resolves to only one of them at a time in theservice_testrule, so the port is still only bound once.Testing
Added
tests/ports, which covers internal ports, an external service, theselect()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
/my/appor something. I don't think this would be too hard to add in a follow-up, though.Solves: #70